updateWidget and reloadWidgets. A widget cannot be drawn from here — that is CraftWidgetProvider's job, in the app's own process, when the system asks. These two are the page's side of it: write what the widget should say into a preferences file the provider reads, then broadcast so it reads it now.
The broadcast action is passed across, not rebuilt
The shim broadcasts ".WIDGET_UPDATE", and CraftWidgetProvider filters on the same templated constant. Zig could build it from activity.getPackageName(), and today that produces the same string — the generated build.gradle.kts sets applicationId = "" with no suffix.
But an app author adding applicationIdSuffix = ".debug" to a build type moves the runtime package name and leaves the constant where it was. Zig would then broadcast an action no filter matches: nothing throws, nothing logs, and the widget silently stops updating in debug builds only — which is where someone would be watching it.
So the Kotlin hands its own constant across, the way it hands the database connection across. Two sides cannot disagree when only one of them decides.
setPackage still uses the runtime package name, because that is a delivery restriction rather than the action — exactly why it is safe there and would not be safe as the action itself.
has and getString are not the same question
updateWidget writes a key only when data.has(name), so a field the page left out keeps whatever the widget last showed rather than being cleared. An empty string is not that case — it is a value the page chose, and it is written. Both have tests, because collapsing them is the natural rewrite.
has is also true for an explicit null, and getString then returns the four characters "null" — so {"title": null} sets the widget's title to the word null rather than clearing it. JSON.stringify produces that shape every day, so it is reproduced.
The field-to-key mapping (title in, widget_title out) is a table rather than four if blocks, and the test asserts all four pairs: a mismatch there writes a preference CraftWidgetProvider never reads, with nothing at all to notice.
reloadWidgets rejects where the shim hangs
The shim wraps none of reloadWidgets in a try/catch, so a sendBroadcast that throws escapes the @JavascriptInterface method and the promise never settles. Unlike getCalendarEvents (#157) there is nothing to fall through to — the shim would hang — so this one rejects, and the code says why rather than reproducing a hang for symmetry.
Testing
zig build test:android passes with the ratchet at 75. Both mutations fired before this was committed: removing the "null" coercion, and changing one preference key, each fail a named test. Both libcraft.so targets still build with no NDK.