Two actions, sharing the JNI surface the first one needed. deleteCalendarEvent proved the reply channel could carry true; these carry a payload built from a ContentProvider and a payload read from the page, which is where the encoding decisions actually start.
getCalendarEvents — what the shim's JSON does that is not obvious
Three behaviours here are org.json's rather than anything chosen, and all three are reproduced:
put(name, null)removes the mapping. A row whose_idis null produces an object with noidkey at all — not"id":null. The other five text columns are read with?: "", which is a different answer to the same null.- Key order is insertion order, because
JSONObjectis aLinkedHashMap. The test asserts the rendered object as one whole string, because the order is the part a rewrite would quietly lose. isAllDayisgetInt(6) == 1, so a 2 is false. Kept as== 1rather than relaxed to!= 0— the column only ever holds 0 or 1, which is exactly why a disagreement about a third value would go unnoticed. That row is in the table.
The column names are literals, and that is the faithful choice: CalendarContract.Events.TITLE is public static final String TITLE = "title", a compile-time constant, so javac folds it into the shim's DEX and the shim never reads that field at runtime either. CONTENT_URI is a Uri object, so it is still read through JNI.
The default window wraps. startTime + (30L * 24 * 60 * 60 * 1000) is Kotlin Long addition, which wraps silently — so +% is the faithful operator, and a plain + would panic in a debug build where the shim quietly returns an empty result.
createCalendarEvent — which payloads Zig is allowed to answer
The shim reads the event through org.json, whose accessors coerce: a startDate sent as the string "1700000000000" is a number to optLong, a title sent as 1.5 is the string "1.5" to optString, a "true" is a boolean to optBoolean. Reproducing Double.toString from memory is not a thing to guess at.
So the rule is narrow and written down rather than approximated: Zig serves the shape NewCalendarEvent declares — strings for the text, numbers for the dates, a boolean for the flag — plus absent keys and explicit nulls. Anything else makes parseNewEvent return null, the native returns false, and the shim reads it with the coercions it already has. Nothing has been settled at that point, so the page cannot tell.
That also covers the parsers disagreeing: JSONTokener is lenient where std.json is strict — unquoted keys, single quotes, a trailing comma — and the difference only ever sends work back to the shim, never the other way.
The one coercion that is reproduced is the surprising one. optString on an explicit JSON null returns the four characters "null", because JSONObject.NULL.toString() is "null" and JSON.toString reaches for toString on anything that is not already a String. optLong on the same null takes the fallback instead. JSON.stringify({location: null}) produces this every day, so it is a live path — and it has a test that fails when the quirk is removed.
Two things ported faithfully that look like bugs: the shim writes CALENDAR_ID = 1 unconditionally (not the primary calendar — the row whose _id is 1), and its two date defaults are separate System.currentTimeMillis() calls, so the default end really can be a millisecond more than start + 3600000. That is why parseNewEvent takes two defaults rather than computing the second from the first.
New JNI surface
String[]arrays:NewObjectArray/SetObjectArrayElement. Unlikelong[]these have no region form, so they are written a slot at a time — the arrays comment now says why the two shapes differ.CallLongMethodAandCallStaticLongMethodA.- Each cursor row runs inside its own local frame. The JVM guarantees only sixteen local slots, and overflowing the table aborts the process rather than throwing, so a long calendar is a real hazard.
ContentValues.putis overloaded on the boxed types, so every number goes throughLong.valueOf/Integer.valueOf.android_permissions.zigcallsContext.checkSelfPermissionandActivity.requestPermissionsdirectly. Not a shortcut around AndroidX: those are exactly the methodsContextCompatandActivityCompatdelegate to from API 23, and staying on the framework keepsandroidx.coreoff a library that builds with no NDK.
System.currentTimeMillis() goes through JNI rather than a host clock, because it is what the shim's defaults are measured from — and because std.time on this toolchain has no wall-clock call that works without libc.
Where getCalendarEvents declines to serve
If the query throws, the native returns false and the shim runs instead. The shim has no try/catch around getCalendarEvents, so the exception escapes the @JavascriptInterface method and the promise hangs — the fourth instance of the family in #127/#148/#154. Rejecting here would be kinder and would also be a divergence nothing records, so it is filed as #157 and left to be fixed on the Kotlin side.
One thing noticed in passing
CalendarEvent in craft.d.ts declares description?: string; // Android, but Android emits notes and reads notes — description is never written or read by either bridge. Left alone here rather than folded into a calendar PR; it belongs with the rest of #140.
Testing
zig build test:android passes with the ratchet at 84. Both mutation checks fired before this was committed: removing the "null" quirk and accepting a float startDate each fail a named test. Both libcraft.so targets still build with no NDK, and JNI_OnLoad is still the only export.