getLocationRecordingState and readLocationRecording are the read side of CraftLocationRecordingStore, which a foreground service writes while a recording runs.
Neither starts, stops, or samples anything — those need the service, and the service needs a LocationCallback, a Java interface Zig cannot implement. So this takes the half that is a preferences read and a file, and leaves the half that is a service where it is.
Both return a String rather than answering through the reply channel, like clipboardRead: null from the native means "ask the shim", and a recording that never started is not null — it is a state object saying so, which is a different answer.
Two nulls that are not the same null
put("id", values.getString("id", null)) // key REMOVED
put("startedAt", values.getLong("startedAt", 0).takeIf { it > 0 } ?: JSONObject.NULL) // key PRESENT, nullJSONObject.put(name, null) removes the mapping, so a store that has never recorded has no id key at all. startedAt is set to JSONObject.NULL explicitly, so its key stays and its value is null. Both are asserted in one test, because the interesting property is that they differ.
That elvis also means a clock that went backwards writes a negative startedAt and the store reports null rather than the number.
A malformed line declines rather than skipping
The store drops unparseable lines and keeps the rest (runCatching { result.put(JSONObject(line)) }).
Zig does not reproduce the dropping. std.json is stricter than JSONTokener, so a line one accepts and the other refuses would make the two disagree about how many samples there are — and sampleCount is a number a page shows a user. Declining hands the whole read back, and the shim drops the line the way it always did.
The likeliest malformed line is a truncated last one, which is what a file cut off mid-write looks like. That case has a test.
sampleCount cannot disagree with the array
renderLocations returns both, from the same pass. The number a page is shown and the samples it reads next come from one traversal rather than two that have to agree — a property of the code rather than something a test keeps checking.
Lines are passed through, not re-serialized
The shim parses each line and prints it again, and append wrote each line with that same printer — so re-printing is the identity for every file this app produced. Passing the bytes through avoids reproducing org.json's number formatting, which is the Double.toString problem the calendar and database modules both decline to guess at.
Reading a file through Java
std.fs on this toolchain needs an Io a JNI native has nowhere to get, so the file is read with File.length() and one FileInputStream.read([B) — readAllBytes is API 33. A short read is treated as a truncated file rather than an error, which is what forEachLine would also see.
Testing
zig build test:android passes with the ratchet at 65. Both mutations fired: emitting "id":"" instead of omitting the key, and dropping the per-line parse check, each fail a named test. Both libcraft.so targets still build with no NDK.