getContacts and addContact — the last large ContentResolver surface, and three different answers to the same missing value.
getContacts: the same null, answered three different ways
Within four lines of the shim:
put("id", id) // null REMOVES the key
put("displayName", name ?: "") // null becomes ""
put("phoneNumbers", getContactPhones(id)) // a null element is KEPT as JSON nullJSONObject.put(name, null) removes the mapping, so a contact without an _id arrives with no id key at all. The elvis on the next line turns the same null into an empty string. And JSONArray.put(Object) stores a null, which JSONStringer writes as null — so the array keeps its length and the page sees a hole rather than a shorter list.
All three are ported, and one test asserts all three at once, because the interesting property is that they differ.
Two columns that really are the same column
Phone.NUMBER and Email.ADDRESS are both DATA1; Phone.TYPE and Email.TYPE are both DATA2. A data row's meaning comes from its MIMETYPE, and the generic columns hold whichever value that row is. Writing "data1" twice is what the shim compiles to, and the test says so rather than leaving it looking like a copy-paste slip.
The 2N+1 loop is reproduced on purpose
getContacts runs one query for the list and two more per contact. Two thousand contacts is 4,001 queries, each a Binder round trip, on the JavaBridge thread, with a null projection pulling every column of a wide denormalized view.
Three queries and an in-memory join would produce the same output — but a different mixture under a concurrent edit, and ContentResolver gives no snapshot across queries either way. Ported as-is so the port stays auditable against the Kotlin line by line, and filed as #165: it is the wrong algorithm in both languages now rather than in one.
The one departure: getColumnIndexOrThrow is resolved once per cursor rather than once per row. Not observable — the answer cannot change while a cursor is open.
addContact: the MIME types are read, where the column names are written
Every other constant here is a literal, because a wrong column name fails loudly — the provider throws, or the cursor comes back empty. A wrong MIMETYPE does not: applyBatch accepts it, the row is written, and it simply never appears as a name or a phone number anywhere. No error, nothing to notice until someone opens the address book.
So CONTENT_ITEM_TYPE is read through JNI. The value is identical to what javac folded into the shim; reading it is one static field access that cannot be misremembered. The asymmetry is the whole argument, and it is in the module comment rather than left looking like an inconsistency.
An explicit null becomes a contact named "null"
optString on JSON null returns the four characters "null", which passes isNotEmpty() and is written to the address book. JSON.stringify({displayName: null}) produces exactly that, so it is a live path — reproduced, with a test that fails when the quirk is removed.
Otherwise the same rule createCalendarEvent set: serve the declared shape, hand back anything only org.json's coercions would read, and decline before any JNI call has touched the provider.
The batch itself is a raw contact with a null account — local-only, which is what the shim creates — then up to three data rows referencing it by withValueBackReference(RAW_CONTACT_ID, 0), since the row it points at does not exist until the batch runs. An empty field writes no row rather than an empty one. The id resolves as a JSON string, because the shim interpolated its Long into a quoted JavaScript string and the page has always received text.
Where getContacts declines to serve
If the query throws, the native returns false and the shim runs. getContacts has no try/catch, so the exception escapes the @JavascriptInterface method and the promise hangs — the same shape as #157, which covers getCalendarEvents for the same reason.
Testing
zig build test:android passes with the ratchet at 77. Three mutations fired before these were committed: emitting "id":"" for a null id, dropping null array elements, and removing the "null" coercion each fail a named test. Both libcraft.so targets still build with no NDK.