ReviewOS

also looking at this

craft-native/craft

feat(android): the contacts pair in Zig — ratchet 79 → 77

#166
Merged chris wants to merge feat/android-contacts into main
7 files +974 -3
9 days ago

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 null

JSONObject.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.

7 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.