ReviewOS

also looking at this

craft-native/craft

fix(android): make every Zig decline speak, and prove Zig answers

#205
Merged glennmichael123 wants to merge fix/android-zig-serves into main
8 files +616 -255
.github/workflows/README.mdmodified+6-1
Changes to .github/workflows/README.md
@@ -50,9 +50,14 @@ bun run test:mobile-e2e:android
5050
5151```
5252ok android-shim — 4/4 cases, no zig
53ok android-runtime — 4/4 cases, zig served registered:59
53ok android-runtime — 4/4 cases, zig served registered:103, declines:0
5454```
5555
56The runtime leg fails on any line where a Zig native gave up — "fell through
57to the shim", "failed with no fallback" or "could not reach the page" — because
58Kotlin answering in its place is invisible to the page, and a runtime that has
59loaded but is not answering would otherwise pass.
60
5661Both write everything they saw — console logs, build logs, a screenshot, the
5762exact page that ran and a `report.json` — to
5863`artifacts/mobile-e2e/<label>/`, which is what CI uploads.
packages/zig/build.zigmodified+17-0
Changes to packages/zig/build.zig
@@ -628,6 +628,21 @@ pub fn build(b: *std.Build) void {
628628 });
629629 const run_android_escaping_tests = b.addRunArtifact(android_escaping_tests);
630630
631 // Every way a native gives up has to say so. See the file for why: a
632 // decline to the Kotlin shim is invisible to the page, which made
633 // ~170 silent `catch return null` paths invisible to everyone.
634 const android_declines_tests = b.addTest(.{
635 .root_module = b.createModule(.{
636 .root_source_file = b.path("test/android_declines_test.zig"),
637 .target = target,
638 .optimize = optimize,
639 }),
640 });
641 android_declines_tests.root_module.addAnonymousImport("android_dispatch.zig", .{
642 .root_source_file = b.path("src/android_dispatch.zig"),
643 });
644 const run_android_declines_tests = b.addRunArtifact(android_declines_tests);
645
631646 ios_conformance_tests.root_module.addAnonymousImport("CraftApp.swift", .{
632647 .root_source_file = b.path("../ios/templates/CraftApp.swift"),
633648 });
@@ -1799,6 +1814,7 @@ pub fn build(b: *std.Build) void {
17991814 test_step.dependOn(&run_android_conformance_tests.step);
18001815 test_step.dependOn(&run_android_bridge_tests.step);
18011816 test_step.dependOn(&run_android_escaping_tests.step);
1817 test_step.dependOn(&run_android_declines_tests.step);
18021818 test_step.dependOn(&run_menubar_tests.step);
18031819 test_step.dependOn(&run_components_tests.step);
18041820 test_step.dependOn(&run_gpu_tests.step);
@@ -2562,6 +2578,7 @@ pub fn build(b: *std.Build) void {
25622578 test_android_step.dependOn(&run_android_conformance_tests.step);
25632579 test_android_step.dependOn(&run_android_bridge_tests.step);
25642580 test_android_step.dependOn(&run_android_escaping_tests.step);
2581 test_android_step.dependOn(&run_android_declines_tests.step);
25652582
25662583 // Add Android tests to the main test step
25672584 test_step.dependOn(&run_android_tests.step);
packages/zig/src/android_dispatch.zigmodified+271-239
Changes to packages/zig/src/android_dispatch.zig
@@ -101,6 +101,46 @@ const Jni = jni.Jni;
101101/// individually on the way out.
102102const backing = std.heap.page_allocator;
103103
104// =============================================================================
105// Declines, said out loud
106// =============================================================================
107//
108// Every way a native can give up goes through one of these three, and each
109// says something different because each *means* something different:
110//
111// - `fellThrough`: a native with a return value gave the action back to the
112// Kotlin shim. The page still gets an answer; Zig just did not produce it.
113// - `failedWithoutFallback`: a callback into Zig gave up early. Zig owns the
114// operation by then, so nothing picks it up, and whatever the page is waiting
115// on will not arrive.
116// - `undelivered`: Zig had an answer and could not get it to the page — the
117// settle, reject or event failed, and that promise will not settle.
118//
119// These used to be ~170 bare `catch return null` / `catch {}`. On a device
120// they were indistinguishable from a native doing its job, which is how
121// `getDeviceInfo` ran on its first emulator for a whole CI cycle throwing
122// `NoSuchFieldError` and handing every call to Kotlin without the page, the
123// suite or the logs noticing. `test/android_declines_test.zig` keeps them from
124// coming back, and the mobile E2E runtime leg fails on any of these lines.
125
126/// A native handing the action back to Kotlin because something it needed
127/// failed. Returns `declined` — null or `JNI_FALSE` — so it reads as the value
128/// the native was always going to return.
129fn fellThrough(comptime action: []const u8, err: anyerror, declined: anytype) @TypeOf(declined) {
130 std.log.warn("craft: " ++ action ++ " fell through to the shim ({s})", .{@errorName(err)});
131 return declined;
132}
133
134/// A callback into Zig giving up with nothing to fall back to.
135fn failedWithoutFallback(comptime action: []const u8, err: anyerror) void {
136 std.log.err("craft: " ++ action ++ " failed with no fallback ({s})", .{@errorName(err)});
137}
138
139/// An answer that could not be delivered to the page.
140fn undelivered(comptime action: []const u8, err: anyerror) void {
141 std.log.err("craft: " ++ action ++ " could not reach the page ({s})", .{@errorName(err)});
142}
143
104144/// `CraftBridge.REQUEST_CALENDAR`, the request code the shim passes.
105145///
106146/// It has to match, because it is the only thing that would tell an
@@ -721,12 +761,12 @@ fn nativeGetDeviceInfo(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c
721761 return null;
722762 };
723763
724 const json = device.render(allocator, info) catch return null;
764 const json = device.render(allocator, info) catch |err| return fellThrough("getDeviceInfo", err, @as(jni.jstring, null));
725765
726766 // Re-encoded rather than handed over: this JSON carries device text — a
727767 // model name, a carrier — and `NewStringUTF` reads modified UTF-8, where a
728768 // NUL is two bytes and an astral character is a surrogate pair.
729 return j.newStringUtf8(allocator, json) catch null;
769 return j.newStringUtf8(allocator, json) catch |err| fellThrough("getDeviceInfo", err, @as(jni.jstring, null));
730770}
731771
732772/// `nativeGetMemoryUsage()` — the JVM heap, as JSON.
@@ -742,8 +782,8 @@ fn nativeGetMemoryUsage(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jstrin
742782 return null;
743783 };
744784
745 const json = system.renderMemory(allocator, usage) catch return null;
746 return j.newStringUtf8(allocator, json) catch null;
785 const json = system.renderMemory(allocator, usage) catch |err| return fellThrough("getMemoryUsage", err, @as(jni.jstring, null));
786 return j.newStringUtf8(allocator, json) catch |err| fellThrough("getMemoryUsage", err, @as(jni.jstring, null));
747787}
748788
749789/// `nativeLog(message)` — returns whether Zig wrote the line.
@@ -758,7 +798,7 @@ fn nativeLog(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv(.c)
758798 defer arena.deinit();
759799 const allocator = arena.allocator();
760800
761 const text = j.stringToUtf8(allocator, message) catch return jni.JNI_FALSE;
801 const text = j.stringToUtf8(allocator, message) catch |err| return fellThrough("log", err, jni.JNI_FALSE);
762802
763803 system.writeLog(j, allocator, text) catch |err| {
764804 std.log.warn("craft: log fell through to the shim ({s})", .{@errorName(err)});
@@ -784,7 +824,7 @@ fn nativeClipboardRead(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c
784824 return null;
785825 };
786826
787 return j.newStringUtf8(allocator, text) catch null;
827 return j.newStringUtf8(allocator, text) catch |err| fellThrough("clipboardRead", err, @as(jni.jstring, null));
788828}
789829
790830/// `nativeClipboardWrite(activity, text)`.
@@ -800,7 +840,7 @@ fn nativeClipboardWrite(
800840 defer arena.deinit();
801841 const allocator = arena.allocator();
802842
803 const value = j.stringToUtf8(allocator, text) catch return jni.JNI_FALSE;
843 const value = j.stringToUtf8(allocator, text) catch |err| return fellThrough("clipboardWrite", err, jni.JNI_FALSE);
804844
805845 clipboard.write(j, allocator, activity, value) catch |err| {
806846 std.log.warn("craft: clipboardWrite fell through to the shim ({s})", .{@errorName(err)});
@@ -809,15 +849,6 @@ fn nativeClipboardWrite(
809849 return jni.JNI_TRUE;
810850}
811851
812/// A `jstring` as arena-owned UTF-8, or null.
813///
814/// Every native below needs this and none of them can share the result, since
815/// each arena dies with its call. A plain slice: the JNI side re-encodes on
816/// the way back out, so nothing here has to carry a terminator.
817fn ownedUtf8(j: Jni, allocator: std.mem.Allocator, str: jni.jstring) ?[]u8 {
818 return j.stringToUtf8(allocator, str) catch null;
819}
820
821852fn nativeOpenUrl(
822853 env: jni.JNIEnv,
823854 _: jni.jobject,
@@ -830,7 +861,7 @@ fn nativeOpenUrl(
830861
831862 const allocator = arena.allocator();
832863
833 const value = ownedUtf8(j, allocator, url) orelse return jni.JNI_FALSE;
864 const value = j.stringToUtf8(allocator, url) catch |err| return fellThrough("openUrl", err, jni.JNI_FALSE);
834865 intents.openUrl(j, allocator, activity, value) catch |err| {
835866 // Not a warning. `openURL` answering false is the documented outcome
836867 // when nothing on the device handles the scheme, and the Kotlin
@@ -854,8 +885,8 @@ fn nativeShare(
854885 defer arena.deinit();
855886 const allocator = arena.allocator();
856887
857 const body = ownedUtf8(j, allocator, text) orelse return jni.JNI_FALSE;
858 const subject = ownedUtf8(j, allocator, title) orelse return jni.JNI_FALSE;
888 const body = j.stringToUtf8(allocator, text) catch |err| return fellThrough("share", err, jni.JNI_FALSE);
889 const subject = j.stringToUtf8(allocator, title) catch |err| return fellThrough("share", err, jni.JNI_FALSE);
859890
860891 intents.share(j, allocator, activity, body, subject) catch |err| {
861892 std.log.warn("craft: share fell through to the shim ({s})", .{@errorName(err)});
@@ -917,7 +948,7 @@ fn nativeStartVideoRecording(
917948}
918949
919950fn nativeReviewSucceeded(_: jni.JNIEnv, _: jni.jobject) callconv(.c) void {
920 events.settle(backing, review.resolve_global, "true") catch {};
951 events.settle(backing, review.resolve_global, "true") catch |err| undelivered("reviewSucceeded", err);
921952}
922953
923954fn nativeReviewError(
@@ -930,9 +961,9 @@ fn nativeReviewError(
930961 defer arena.deinit();
931962 const allocator = arena.allocator();
932963
933 const text = j.stringToUtf8(allocator, message) catch return;
934 const payload = review.rejectionPayload(allocator, text) catch return;
935 events.settle(allocator, review.reject_global, payload) catch {};
964 const text = j.stringToUtf8(allocator, message) catch |err| return failedWithoutFallback("reviewError", err);
965 const payload = review.rejectionPayload(allocator, text) catch |err| return failedWithoutFallback("reviewError", err);
966 events.settle(allocator, review.reject_global, payload) catch |err| undelivered("reviewError", err);
936967}
937968
938969fn nativeRequestReview(
@@ -948,7 +979,8 @@ fn nativeRequestReview(
948979}
949980
950981fn speechHaptic(j: Jni, activity: jni.jobject) void {
951 haptics.play(j, activity, haptics.effectForStyle("light")) catch {};
982 haptics.play(j, activity, haptics.effectForStyle("light")) catch |err|
983 std.log.warn("craft: speech start haptic skipped ({s})", .{@errorName(err)});
952984}
953985
954986fn nativeSpeechReady(
@@ -956,7 +988,7 @@ fn nativeSpeechReady(
956988 _: jni.jobject,
957989 activity: jni.jobject,
958990) callconv(.c) void {
959 events.emitEvent(backing, speech.start_event, "{}") catch {};
991 events.emitEvent(backing, speech.start_event, "{}") catch |err| undelivered("speechReady", err);
960992 speechHaptic(Jni.init(env), activity);
961993}
962994
@@ -970,8 +1002,8 @@ fn nativeSpeechError(
9701002 defer arena.deinit();
9711003 const allocator = arena.allocator();
9721004
973 const detail = speech.errorDetail(allocator, speech.errorMessage(error_code)) catch return;
974 events.emitEvent(allocator, speech.error_event, detail) catch {};
1005 const detail = speech.errorDetail(allocator, speech.errorMessage(error_code)) catch |err| return failedWithoutFallback("speechError", err);
1006 events.emitEvent(allocator, speech.error_event, detail) catch |err| undelivered("speechError", err);
9751007 speechHaptic(Jni.init(env), activity);
9761008}
9771009
@@ -987,12 +1019,12 @@ fn nativeSpeechResult(
9871019 defer arena.deinit();
9881020 const allocator = arena.allocator();
9891021
990 const text = j.stringToUtf8(allocator, transcript) catch return;
1022 const text = j.stringToUtf8(allocator, transcript) catch |err| return failedWithoutFallback("speechResult", err);
9911023 const final = is_final == jni.JNI_TRUE;
992 const detail = speech.resultDetail(allocator, text, final) catch return;
993 events.emitEvent(allocator, speech.result_event, detail) catch {};
1024 const detail = speech.resultDetail(allocator, text, final) catch |err| return failedWithoutFallback("speechResult", err);
1025 events.emitEvent(allocator, speech.result_event, detail) catch |err| undelivered("speechResult", err);
9941026 if (final) {
995 events.emitEvent(allocator, speech.end_event, "{}") catch {};
1027 events.emitEvent(allocator, speech.end_event, "{}") catch |err| undelivered("speechResult", err);
9961028 speechHaptic(j, activity);
9971029 }
9981030}
@@ -1007,19 +1039,19 @@ fn nativeStartListening(
10071039 var arena = std.heap.ArenaAllocator.init(backing);
10081040 defer arena.deinit();
10091041 const allocator = arena.allocator();
1010 const detail = speech.errorDetail(allocator, speech.unavailable) catch return jni.JNI_FALSE;
1011 events.emitEvent(allocator, speech.error_event, detail) catch return jni.JNI_FALSE;
1042 const detail = speech.errorDetail(allocator, speech.unavailable) catch |err| return fellThrough("startListening", err, jni.JNI_FALSE);
1043 events.emitEvent(allocator, speech.error_event, detail) catch |err| return fellThrough("startListening", err, jni.JNI_FALSE);
10121044 return jni.JNI_TRUE;
10131045 }
10141046
10151047 const j = Jni.init(env);
1016 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1048 const holder = j.findClass(holder_class) catch |err| return fellThrough("startListening", err, jni.JNI_FALSE);
10171049 j.callStaticVoidMethodA(
10181050 holder,
10191051 j.staticMethodId(holder, "beginSpeechRecognition", "(Landroid/app/Activity;)V") catch
10201052 return jni.JNI_FALSE,
10211053 &.{.{ .l = activity }},
1022 ) catch return jni.JNI_FALSE;
1054 ) catch |err| return fellThrough("startListening", err, jni.JNI_FALSE);
10231055 return jni.JNI_TRUE;
10241056}
10251057
@@ -1029,18 +1061,18 @@ fn nativeStopListening(
10291061 activity: jni.jobject,
10301062) callconv(.c) jni.jboolean {
10311063 const j = Jni.init(env);
1032 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1064 const holder = j.findClass(holder_class) catch |err| return fellThrough("stopListening", err, jni.JNI_FALSE);
10331065 j.callStaticVoidMethodA(
10341066 holder,
10351067 j.staticMethodId(holder, "endSpeechRecognition", "(Landroid/app/Activity;)V") catch
10361068 return jni.JNI_FALSE,
10371069 &.{.{ .l = activity }},
1038 ) catch return jni.JNI_FALSE;
1070 ) catch |err| return fellThrough("stopListening", err, jni.JNI_FALSE);
10391071 return jni.JNI_TRUE;
10401072}
10411073
10421074fn nativeAudioStarted(_: jni.JNIEnv, _: jni.jobject) callconv(.c) void {
1043 events.settle(backing, audio.start_resolve_global, "true") catch {};
1075 events.settle(backing, audio.start_resolve_global, "true") catch |err| undelivered("audioStarted", err);
10441076}
10451077
10461078fn settleAudioError(env: jni.JNIEnv, global: []const u8, message: jni.jstring) void {
@@ -1049,9 +1081,9 @@ fn settleAudioError(env: jni.JNIEnv, global: []const u8, message: jni.jstring) v
10491081 defer arena.deinit();
10501082 const allocator = arena.allocator();
10511083
1052 const text = j.stringToUtf8(allocator, message) catch return;
1053 const payload = audio.stringPayload(allocator, text) catch return;
1054 events.settle(allocator, global, payload) catch {};
1084 const text = j.stringToUtf8(allocator, message) catch |err| return failedWithoutFallback("settleAudioError", err);
1085 const payload = audio.stringPayload(allocator, text) catch |err| return failedWithoutFallback("settleAudioError", err);
1086 events.settle(allocator, global, payload) catch |err| undelivered("settleAudioError", err);
10551087}
10561088
10571089fn nativeAudioStartError(
@@ -1072,9 +1104,9 @@ fn nativeAudioStopped(
10721104 defer arena.deinit();
10731105 const allocator = arena.allocator();
10741106
1075 const raw = j.byteArrayToOwned(allocator, bytes) catch return;
1076 const payload = audio.recordingPayload(allocator, raw) catch return;
1077 events.settle(allocator, audio.stop_resolve_global, payload) catch {};
1107 const raw = j.byteArrayToOwned(allocator, bytes) catch |err| return failedWithoutFallback("audioStopped", err);
1108 const payload = audio.recordingPayload(allocator, raw) catch |err| return failedWithoutFallback("audioStopped", err);
1109 events.settle(allocator, audio.stop_resolve_global, payload) catch |err| undelivered("audioStopped", err);
10781110}
10791111
10801112fn nativeAudioStopError(
@@ -1105,13 +1137,13 @@ fn nativeStartAudioRecording(
11051137 return jni.JNI_TRUE;
11061138 }
11071139
1108 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1140 const holder = j.findClass(holder_class) catch |err| return fellThrough("startAudioRecording", err, jni.JNI_FALSE);
11091141 j.callStaticVoidMethodA(
11101142 holder,
11111143 j.staticMethodId(holder, "beginAudioRecording", "(Landroid/app/Activity;)V") catch
11121144 return jni.JNI_FALSE,
11131145 &.{.{ .l = activity }},
1114 ) catch return jni.JNI_FALSE;
1146 ) catch |err| return fellThrough("startAudioRecording", err, jni.JNI_FALSE);
11151147 return jni.JNI_TRUE;
11161148}
11171149
@@ -1121,12 +1153,12 @@ fn nativeStopAudioRecording(
11211153 _: jni.jobject,
11221154) callconv(.c) jni.jboolean {
11231155 const j = Jni.init(env);
1124 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1156 const holder = j.findClass(holder_class) catch |err| return fellThrough("stopAudioRecording", err, jni.JNI_FALSE);
11251157 j.callStaticVoidMethodA(
11261158 holder,
1127 j.staticMethodId(holder, "endAudioRecording", "()V") catch return jni.JNI_FALSE,
1159 j.staticMethodId(holder, "endAudioRecording", "()V") catch |err| return fellThrough("stopAudioRecording", err, jni.JNI_FALSE),
11281160 &.{},
1129 ) catch return jni.JNI_FALSE;
1161 ) catch |err| return fellThrough("stopAudioRecording", err, jni.JNI_FALSE);
11301162 return jni.JNI_TRUE;
11311163}
11321164
@@ -1137,13 +1169,13 @@ fn nativeResetDeepLinks(_: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jboolean
11371169
11381170fn nativeSetInitialURL(env: jni.JNIEnv, _: jni.jobject, url: jni.jstring) callconv(.c) jni.jboolean {
11391171 if (url == null) {
1140 deeplink.set(null) catch return jni.JNI_FALSE;
1172 deeplink.set(null) catch |err| return fellThrough("setInitialURL", err, jni.JNI_FALSE);
11411173 return jni.JNI_TRUE;
11421174 }
11431175 var arena = std.heap.ArenaAllocator.init(backing);
11441176 defer arena.deinit();
1145 const text = Jni.init(env).stringToUtf8(arena.allocator(), url) catch return jni.JNI_FALSE;
1146 deeplink.set(text) catch return jni.JNI_FALSE;
1177 const text = Jni.init(env).stringToUtf8(arena.allocator(), url) catch |err| return fellThrough("setInitialURL", err, jni.JNI_FALSE);
1178 deeplink.set(text) catch |err| return fellThrough("setInitialURL", err, jni.JNI_FALSE);
11471179 return jni.JNI_TRUE;
11481180}
11491181
@@ -1152,9 +1184,9 @@ fn nativeGetInitialURL(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jboolea
11521184 var arena = std.heap.ArenaAllocator.init(backing);
11531185 defer arena.deinit();
11541186 const allocator = arena.allocator();
1155 const url = deeplink.snapshot(allocator) catch return jni.JNI_FALSE;
1156 const result = if (url) |text| deeplink.payload(j, allocator, text) catch return jni.JNI_FALSE else "null";
1157 events.settle(allocator, deeplink.resolve_global, result) catch return jni.JNI_FALSE;
1187 const url = deeplink.snapshot(allocator) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE);
1188 const result = if (url) |text| deeplink.payload(j, allocator, text) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE) else "null";
1189 events.settle(allocator, deeplink.resolve_global, result) catch |err| return fellThrough("getInitialURL", err, jni.JNI_FALSE);
11581190 return jni.JNI_TRUE;
11591191}
11601192
@@ -1163,10 +1195,10 @@ fn nativeDispatchDeepLink(env: jni.JNIEnv, _: jni.jobject, url: jni.jstring) cal
11631195 var arena = std.heap.ArenaAllocator.init(backing);
11641196 defer arena.deinit();
11651197 const allocator = arena.allocator();
1166 const text = j.stringToUtf8(allocator, url) catch return jni.JNI_FALSE;
1167 deeplink.rememberFirst(text) catch return jni.JNI_FALSE;
1168 const detail = deeplink.payload(j, allocator, text) catch return jni.JNI_FALSE;
1169 events.emitEvent(allocator, deeplink.event_name, detail) catch return jni.JNI_FALSE;
1198 const text = j.stringToUtf8(allocator, url) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
1199 deeplink.rememberFirst(text) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
1200 const detail = deeplink.payload(j, allocator, text) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
1201 events.emitEvent(allocator, deeplink.event_name, detail) catch |err| return fellThrough("dispatchDeepLink", err, jni.JNI_FALSE);
11701202 return jni.JNI_TRUE;
11711203}
11721204
@@ -1175,9 +1207,9 @@ fn nativeScreenshotReady(env: jni.JNIEnv, _: jni.jobject, bytes: jni.jobject) ca
11751207 var arena = std.heap.ArenaAllocator.init(backing);
11761208 defer arena.deinit();
11771209 const allocator = arena.allocator();
1178 const raw = j.byteArrayToOwned(allocator, bytes) catch return;
1179 const payload = screenshot.imagePayload(allocator, raw) catch return;
1180 events.settle(allocator, screenshot.resolve_global, payload) catch {};
1210 const raw = j.byteArrayToOwned(allocator, bytes) catch |err| return failedWithoutFallback("screenshotReady", err);
1211 const payload = screenshot.imagePayload(allocator, raw) catch |err| return failedWithoutFallback("screenshotReady", err);
1212 events.settle(allocator, screenshot.resolve_global, payload) catch |err| undelivered("screenshotReady", err);
11811213}
11821214
11831215fn nativeScreenshotError(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv(.c) void {
@@ -1185,9 +1217,9 @@ fn nativeScreenshotError(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring)
11851217 var arena = std.heap.ArenaAllocator.init(backing);
11861218 defer arena.deinit();
11871219 const allocator = arena.allocator();
1188 const text = j.stringToUtf8(allocator, message) catch return;
1189 const payload = screenshot.errorPayload(allocator, text) catch return;
1190 events.settle(allocator, screenshot.reject_global, payload) catch {};
1220 const text = j.stringToUtf8(allocator, message) catch |err| return failedWithoutFallback("screenshotError", err);
1221 const payload = screenshot.errorPayload(allocator, text) catch |err| return failedWithoutFallback("screenshotError", err);
1222 events.settle(allocator, screenshot.reject_global, payload) catch |err| undelivered("screenshotError", err);
11911223}
11921224
11931225fn nativeTakeScreenshot(
@@ -1197,13 +1229,13 @@ fn nativeTakeScreenshot(
11971229 web_view: jni.jobject,
11981230) callconv(.c) jni.jboolean {
11991231 const j = Jni.init(env);
1200 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1232 const holder = j.findClass(holder_class) catch |err| return fellThrough("takeScreenshot", err, jni.JNI_FALSE);
12011233 j.callStaticVoidMethodA(
12021234 holder,
12031235 j.staticMethodId(holder, "captureScreenshot", "(Landroid/app/Activity;Landroid/webkit/WebView;)V") catch
12041236 return jni.JNI_FALSE,
12051237 &.{ .{ .l = activity }, .{ .l = web_view } },
1206 ) catch return jni.JNI_FALSE;
1238 ) catch |err| return fellThrough("takeScreenshot", err, jni.JNI_FALSE);
12071239 return jni.JNI_TRUE;
12081240}
12091241
@@ -1211,8 +1243,8 @@ fn settleRawString(env: jni.JNIEnv, global: []const u8, value: jni.jstring) void
12111243 const j = Jni.init(env);
12121244 var arena = std.heap.ArenaAllocator.init(backing);
12131245 defer arena.deinit();
1214 const text = j.stringToUtf8(arena.allocator(), value) catch return;
1215 events.settle(arena.allocator(), global, text) catch {};
1246 const text = j.stringToUtf8(arena.allocator(), value) catch |err| return failedWithoutFallback("settleRawString", err);
1247 events.settle(arena.allocator(), global, text) catch |err| undelivered("settleRawString", err);
12161248}
12171249
12181250fn settleBillingError(env: jni.JNIEnv, global: []const u8, value: jni.jstring) void {
@@ -1220,9 +1252,9 @@ fn settleBillingError(env: jni.JNIEnv, global: []const u8, value: jni.jstring) v
12201252 var arena = std.heap.ArenaAllocator.init(backing);
12211253 defer arena.deinit();
12221254 const allocator = arena.allocator();
1223 const text = j.stringToUtf8(allocator, value) catch return;
1224 const payload = billing.errorPayload(allocator, text) catch return;
1225 events.settle(allocator, global, payload) catch {};
1255 const text = j.stringToUtf8(allocator, value) catch |err| return failedWithoutFallback("settleBillingError", err);
1256 const payload = billing.errorPayload(allocator, text) catch |err| return failedWithoutFallback("settleBillingError", err);
1257 events.settle(allocator, global, payload) catch |err| undelivered("settleBillingError", err);
12261258}
12271259
12281260fn nativeProductsReady(env: jni.JNIEnv, _: jni.jobject, json: jni.jstring) callconv(.c) void {
@@ -1245,12 +1277,12 @@ fn nativeGetProducts(
12451277 ids_json: jni.jstring,
12461278) callconv(.c) jni.jboolean {
12471279 const j = Jni.init(env);
1248 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1280 const holder = j.findClass(holder_class) catch |err| return fellThrough("getProducts", err, jni.JNI_FALSE);
12491281 j.callStaticVoidMethodA(
12501282 holder,
1251 j.staticMethodId(holder, "queryProducts", "(Landroid/app/Activity;Ljava/lang/String;)V") catch return jni.JNI_FALSE,
1283 j.staticMethodId(holder, "queryProducts", "(Landroid/app/Activity;Ljava/lang/String;)V") catch |err| return fellThrough("getProducts", err, jni.JNI_FALSE),
12521284 &.{ .{ .l = activity }, .{ .l = ids_json } },
1253 ) catch return jni.JNI_FALSE;
1285 ) catch |err| return fellThrough("getProducts", err, jni.JNI_FALSE);
12541286 return jni.JNI_TRUE;
12551287}
12561288
@@ -1260,12 +1292,12 @@ fn nativeRestorePurchases(
12601292 activity: jni.jobject,
12611293) callconv(.c) jni.jboolean {
12621294 const j = Jni.init(env);
1263 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1295 const holder = j.findClass(holder_class) catch |err| return fellThrough("restorePurchases", err, jni.JNI_FALSE);
12641296 j.callStaticVoidMethodA(
12651297 holder,
1266 j.staticMethodId(holder, "queryRestoredPurchases", "(Landroid/app/Activity;)V") catch return jni.JNI_FALSE,
1298 j.staticMethodId(holder, "queryRestoredPurchases", "(Landroid/app/Activity;)V") catch |err| return fellThrough("restorePurchases", err, jni.JNI_FALSE),
12671299 &.{.{ .l = activity }},
1268 ) catch return jni.JNI_FALSE;
1300 ) catch |err| return fellThrough("restorePurchases", err, jni.JNI_FALSE);
12691301 return jni.JNI_TRUE;
12701302}
12711303
@@ -1278,19 +1310,19 @@ fn nativeMlError(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv
12781310 var arena = std.heap.ArenaAllocator.init(backing);
12791311 defer arena.deinit();
12801312 const allocator = arena.allocator();
1281 const text = j.stringToUtf8(allocator, message) catch return;
1282 const payload = ml.errorPayload(allocator, text) catch return;
1283 events.settle(allocator, ml.reject_global, payload) catch {};
1313 const text = j.stringToUtf8(allocator, message) catch |err| return failedWithoutFallback("mlError", err);
1314 const payload = ml.errorPayload(allocator, text) catch |err| return failedWithoutFallback("mlError", err);
1315 events.settle(allocator, ml.reject_global, payload) catch |err| undelivered("mlError", err);
12841316}
12851317
12861318fn startMl(env: jni.JNIEnv, method: [*:0]const u8, image: jni.jstring) jni.jboolean {
12871319 const j = Jni.init(env);
1288 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1320 const holder = j.findClass(holder_class) catch |err| return fellThrough("startMl", err, jni.JNI_FALSE);
12891321 j.callStaticVoidMethodA(
12901322 holder,
1291 j.staticMethodId(holder, method, "(Ljava/lang/String;)V") catch return jni.JNI_FALSE,
1323 j.staticMethodId(holder, method, "(Ljava/lang/String;)V") catch |err| return fellThrough("startMl", err, jni.JNI_FALSE),
12921324 &.{.{ .l = image }},
1293 ) catch return jni.JNI_FALSE;
1325 ) catch |err| return fellThrough("startMl", err, jni.JNI_FALSE);
12941326 return jni.JNI_TRUE;
12951327}
12961328
@@ -1307,7 +1339,7 @@ fn nativeRecognizeText(env: jni.JNIEnv, _: jni.jobject, image: jni.jstring) call
13071339}
13081340
13091341fn nativePdfOpened(_: jni.JNIEnv, _: jni.jobject) callconv(.c) void {
1310 events.settle(backing, pdf.resolve_global, pdf.opened_payload) catch {};
1342 events.settle(backing, pdf.resolve_global, pdf.opened_payload) catch |err| undelivered("pdfOpened", err);
13111343}
13121344
13131345fn nativePdfError(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv(.c) void {
@@ -1315,9 +1347,9 @@ fn nativePdfError(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callcon
13151347 var arena = std.heap.ArenaAllocator.init(backing);
13161348 defer arena.deinit();
13171349 const allocator = arena.allocator();
1318 const text = j.stringToUtf8(allocator, message) catch return;
1319 const payload = pdf.errorPayload(allocator, text) catch return;
1320 events.settle(allocator, pdf.reject_global, payload) catch {};
1350 const text = j.stringToUtf8(allocator, message) catch |err| return failedWithoutFallback("pdfError", err);
1351 const payload = pdf.errorPayload(allocator, text) catch |err| return failedWithoutFallback("pdfError", err);
1352 events.settle(allocator, pdf.reject_global, payload) catch |err| undelivered("pdfError", err);
13211353}
13221354
13231355fn nativeOpenPDF(
@@ -1328,12 +1360,12 @@ fn nativeOpenPDF(
13281360 _: jni.jint,
13291361) callconv(.c) jni.jboolean {
13301362 const j = Jni.init(env);
1331 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1363 const holder = j.findClass(holder_class) catch |err| return fellThrough("openPDF", err, jni.JNI_FALSE);
13321364 j.callStaticVoidMethodA(
13331365 holder,
1334 j.staticMethodId(holder, "openPdfExternal", "(Landroid/app/Activity;Ljava/lang/String;)V") catch return jni.JNI_FALSE,
1366 j.staticMethodId(holder, "openPdfExternal", "(Landroid/app/Activity;Ljava/lang/String;)V") catch |err| return fellThrough("openPDF", err, jni.JNI_FALSE),
13351367 &.{ .{ .l = activity }, .{ .l = source } },
1336 ) catch return jni.JNI_FALSE;
1368 ) catch |err| return fellThrough("openPDF", err, jni.JNI_FALSE);
13371369 return jni.JNI_TRUE;
13381370}
13391371
@@ -1341,7 +1373,7 @@ fn nativeBiometricSucceeded(
13411373 _: jni.JNIEnv,
13421374 _: jni.jobject,
13431375) callconv(.c) void {
1344 events.settle(backing, biometric.resolve_global, "true") catch {};
1376 events.settle(backing, biometric.resolve_global, "true") catch |err| undelivered("biometricSucceeded", err);
13451377}
13461378
13471379fn nativeBiometricError(
@@ -1354,9 +1386,9 @@ fn nativeBiometricError(
13541386 defer arena.deinit();
13551387 const allocator = arena.allocator();
13561388
1357 const text = j.stringToUtf8(allocator, message) catch return;
1358 const payload = biometric.rejectionPayload(allocator, text) catch return;
1359 events.settle(allocator, biometric.reject_global, payload) catch {};
1389 const text = j.stringToUtf8(allocator, message) catch |err| return failedWithoutFallback("biometricError", err);
1390 const payload = biometric.rejectionPayload(allocator, text) catch |err| return failedWithoutFallback("biometricError", err);
1391 events.settle(allocator, biometric.reject_global, payload) catch |err| undelivered("biometricError", err);
13601392}
13611393
13621394fn nativeAuthenticate(
@@ -1374,18 +1406,18 @@ fn nativeAuthenticate(
13741406 const allocator = arena.allocator();
13751407 const payload = biometric.rejectionPayload(allocator, biometric.unsupported_activity) catch
13761408 return jni.JNI_FALSE;
1377 events.settle(allocator, biometric.reject_global, payload) catch return jni.JNI_FALSE;
1409 events.settle(allocator, biometric.reject_global, payload) catch |err| return fellThrough("authenticate", err, jni.JNI_FALSE);
13781410 return jni.JNI_TRUE;
13791411 }
13801412
1381 const holder = j.findClass(holder_class) catch return jni.JNI_FALSE;
1413 const holder = j.findClass(holder_class) catch |err| return fellThrough("authenticate", err, jni.JNI_FALSE);
13821414 j.callStaticVoidMethodA(
13831415 holder,
13841416 j.staticMethodId(
13851417 holder,
13861418 "showBiometricPrompt",
13871419 "(Landroid/app/Activity;Ljava/lang/String;)V",
1388 ) catch return jni.JNI_FALSE,
1420 ) catch |err| return fellThrough("authenticate", err, jni.JNI_FALSE),
13891421 &.{ .{ .l = activity }, .{ .l = reason } },
13901422 ) catch |err| {
13911423 std.log.warn("craft: authenticate fell through to the shim ({s})", .{@errorName(err)});
@@ -1405,8 +1437,8 @@ fn nativeGetNetworkStatus(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject
14051437 return null;
14061438 };
14071439
1408 const json = network.render(allocator, status) catch return null;
1409 return j.newStringUtf8(allocator, json) catch null;
1440 const json = network.render(allocator, status) catch |err| return fellThrough("getNetworkStatus", err, @as(jni.jstring, null));
1441 return j.newStringUtf8(allocator, json) catch |err| fellThrough("getNetworkStatus", err, @as(jni.jstring, null));
14101442}
14111443
14121444fn nativeSecureSet(
@@ -1421,8 +1453,8 @@ fn nativeSecureSet(
14211453 defer arena.deinit();
14221454 const allocator = arena.allocator();
14231455
1424 const k = ownedUtf8(j, allocator, key) orelse return jni.JNI_FALSE;
1425 const v = ownedUtf8(j, allocator, value) orelse return jni.JNI_FALSE;
1456 const k = j.stringToUtf8(allocator, key) catch |err| return fellThrough("secureSet", err, jni.JNI_FALSE);
1457 const v = j.stringToUtf8(allocator, value) catch |err| return fellThrough("secureSet", err, jni.JNI_FALSE);
14261458
14271459 securestore.set(j, allocator, prefs, k, v) catch |err| {
14281460 std.log.warn("craft: secureSet fell through to the shim ({s})", .{@errorName(err)});
@@ -1447,14 +1479,14 @@ fn nativeSecureGet(
14471479 defer arena.deinit();
14481480 const allocator = arena.allocator();
14491481
1450 const k = ownedUtf8(j, allocator, key) orelse return null;
1482 const k = j.stringToUtf8(allocator, key) catch |err| return fellThrough("secureGet", err, @as(jni.jstring, null));
14511483 const value = securestore.get(allocator, j, prefs, k) catch |err| {
14521484 std.log.warn("craft: secureGet fell through to the shim ({s})", .{@errorName(err)});
14531485 return null;
14541486 };
14551487
1456 const json = securestore.renderRead(allocator, value) catch return null;
1457 return j.newStringUtf8(allocator, json) catch null;
1488 const json = securestore.renderRead(allocator, value) catch |err| return fellThrough("secureGet", err, @as(jni.jstring, null));
1489 return j.newStringUtf8(allocator, json) catch |err| fellThrough("secureGet", err, @as(jni.jstring, null));
14581490}
14591491
14601492fn nativeSecureRemove(
@@ -1469,7 +1501,7 @@ fn nativeSecureRemove(
14691501
14701502 const allocator = arena.allocator();
14711503
1472 const k = ownedUtf8(j, allocator, key) orelse return jni.JNI_FALSE;
1504 const k = j.stringToUtf8(allocator, key) catch |err| return fellThrough("secureRemove", err, jni.JNI_FALSE);
14731505 securestore.remove(j, allocator, prefs, k) catch |err| {
14741506 std.log.warn("craft: secureRemove fell through to the shim ({s})", .{@errorName(err)});
14751507 return jni.JNI_FALSE;
@@ -1496,7 +1528,7 @@ fn nativeHaptic(
14961528 var arena = std.heap.ArenaAllocator.init(backing);
14971529 defer arena.deinit();
14981530
1499 const text = j.stringToUtf8(arena.allocator(), style) catch return jni.JNI_FALSE;
1531 const text = j.stringToUtf8(arena.allocator(), style) catch |err| return fellThrough("haptic", err, jni.JNI_FALSE);
15001532 haptics.play(j, activity, haptics.effectForStyle(text)) catch |err| {
15011533 std.log.warn("craft: haptic fell through to the shim ({s})", .{@errorName(err)});
15021534 return jni.JNI_FALSE;
@@ -1515,12 +1547,12 @@ fn nativeVibrate(
15151547 defer arena.deinit();
15161548 const allocator = arena.allocator();
15171549
1518 const json = j.stringToUtf8(allocator, pattern_json) catch return jni.JNI_FALSE;
1550 const json = j.stringToUtf8(allocator, pattern_json) catch |err| return fellThrough("vibrate", err, jni.JNI_FALSE);
15191551
15201552 // Null is a pattern the shim would throw on. Declining lets it throw, log
15211553 // "Vibration error", and vibrate nothing — the same outcome with the log
15221554 // kept. See bridge_android_haptics.parsePattern.
1523 const timings = haptics.parsePattern(allocator, json) catch return jni.JNI_FALSE;
1555 const timings = haptics.parsePattern(allocator, json) catch |err| return fellThrough("vibrate", err, jni.JNI_FALSE);
15241556 if (timings == null) return jni.JNI_FALSE;
15251557
15261558 haptics.play(j, activity, .{ .waveform = timings.? }) catch |err| {
@@ -1573,7 +1605,7 @@ fn nativeDeleteCalendarEvent(
15731605 defer arena.deinit();
15741606 const allocator = arena.allocator();
15751607
1576 const text = j.stringToUtf8(allocator, event_id) catch return jni.JNI_FALSE;
1608 const text = j.stringToUtf8(allocator, event_id) catch |err| return fellThrough("deleteCalendarEvent", err, jni.JNI_FALSE);
15771609
15781610 const id = calendar.parseEventId(text) orelse {
15791611 // What the shim's NumberFormatException produces, with the message it
@@ -1581,11 +1613,11 @@ fn nativeDeleteCalendarEvent(
15811613 // of hanging the promise. See #154.
15821614 var message: std.ArrayListUnmanaged(u8) = .empty;
15831615 defer message.deinit(allocator);
1584 message.appendSlice(allocator, "For input string: \"") catch return jni.JNI_FALSE;
1585 message.appendSlice(allocator, text) catch return jni.JNI_FALSE;
1586 message.append(allocator, '"') catch return jni.JNI_FALSE;
1616 message.appendSlice(allocator, "For input string: \"") catch |err| return fellThrough("deleteCalendarEvent", err, jni.JNI_FALSE);
1617 message.appendSlice(allocator, text) catch |err| return fellThrough("deleteCalendarEvent", err, jni.JNI_FALSE);
1618 message.append(allocator, '"') catch |err| return fellThrough("deleteCalendarEvent", err, jni.JNI_FALSE);
15871619
1588 calendar.rejectWith(allocator, message.items) catch return jni.JNI_FALSE;
1620 calendar.rejectWith(allocator, message.items) catch |err| return fellThrough("deleteCalendarEvent", err, jni.JNI_FALSE);
15891621 return jni.JNI_TRUE;
15901622 };
15911623
@@ -1594,11 +1626,11 @@ fn nativeDeleteCalendarEvent(
15941626 // here, as it lands in the shim's catch. The error name rather than
15951627 // the Java message: the throwable was already described to logcat and
15961628 // cleared by `Jni.check`, so its text is gone by now.
1597 calendar.rejectWith(allocator, @errorName(err)) catch {};
1629 calendar.rejectWith(allocator, @errorName(err)) catch |inner| undelivered("deleteCalendarEvent", inner);
15981630 return jni.JNI_TRUE;
15991631 };
16001632
1601 events.settle(allocator, calendar.resolve_global, "true") catch return jni.JNI_FALSE;
1633 events.settle(allocator, calendar.resolve_global, "true") catch |err| return fellThrough("deleteCalendarEvent", err, jni.JNI_FALSE);
16021634 return jni.JNI_TRUE;
16031635}
16041636
@@ -1644,7 +1676,7 @@ fn nativeGetCalendarEvents(
16441676 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
16451677 return jni.JNI_FALSE;
16461678 };
1647 events.settle(allocator, calendar.list_reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
1679 events.settle(allocator, calendar.list_reject_global, "\"Permission denied\"") catch |err| return fellThrough("getCalendarEvents", err, jni.JNI_FALSE);
16481680 return jni.JNI_TRUE;
16491681 }
16501682
@@ -1661,7 +1693,7 @@ fn nativeGetCalendarEvents(
16611693 return jni.JNI_FALSE;
16621694 };
16631695
1664 events.settle(allocator, calendar.list_resolve_global, payload.items) catch return jni.JNI_FALSE;
1696 events.settle(allocator, calendar.list_resolve_global, payload.items) catch |err| return fellThrough("getCalendarEvents", err, jni.JNI_FALSE);
16651697 return jni.JNI_TRUE;
16661698}
16671699
@@ -1693,11 +1725,11 @@ fn nativeCreateCalendarEvent(
16931725 std.log.warn("craft: createCalendarEvent fell through to the shim ({s})", .{@errorName(err)});
16941726 return jni.JNI_FALSE;
16951727 };
1696 events.settle(allocator, calendar.create_reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
1728 events.settle(allocator, calendar.create_reject_global, "\"Permission denied\"") catch |err| return fellThrough("createCalendarEvent", err, jni.JNI_FALSE);
16971729 return jni.JNI_TRUE;
16981730 }
16991731
1700 const text = j.stringToUtf8(allocator, event_json) catch return jni.JNI_FALSE;
1732 const text = j.stringToUtf8(allocator, event_json) catch |err| return fellThrough("createCalendarEvent", err, jni.JNI_FALSE);
17011733
17021734 // `JSONTokener` is lenient where `std.json` is strict — unquoted keys,
17031735 // single quotes, a trailing comma. Everything strict JSON accepts it
@@ -1706,8 +1738,8 @@ fn nativeCreateCalendarEvent(
17061738 return jni.JNI_FALSE;
17071739 defer parsed.deinit();
17081740
1709 const start_default = calendar.currentTimeMillis(j) catch return jni.JNI_FALSE;
1710 const end_default = calendar.currentTimeMillis(j) catch return jni.JNI_FALSE;
1741 const start_default = calendar.currentTimeMillis(j) catch |err| return fellThrough("createCalendarEvent", err, jni.JNI_FALSE);
1742 const end_default = calendar.currentTimeMillis(j) catch |err| return fellThrough("createCalendarEvent", err, jni.JNI_FALSE);
17111743 const event = calendar.parseNewEvent(
17121744 parsed.value,
17131745 start_default,
@@ -1718,12 +1750,12 @@ fn nativeCreateCalendarEvent(
17181750 // The shim catches here and rejects with the exception's message. The
17191751 // throwable was described to logcat and cleared by `Jni.check` before
17201752 // this point, so the error name is what is left to say.
1721 calendar.rejectOn(allocator, calendar.create_reject_global, @errorName(err)) catch {};
1753 calendar.rejectOn(allocator, calendar.create_reject_global, @errorName(err)) catch |inner| undelivered("createCalendarEvent", inner);
17221754 return jni.JNI_TRUE;
17231755 };
17241756
1725 const payload = calendar.jsonString(allocator, id) catch return jni.JNI_FALSE;
1726 events.settle(allocator, calendar.create_resolve_global, payload) catch return jni.JNI_FALSE;
1757 const payload = calendar.jsonString(allocator, id) catch |err| return fellThrough("createCalendarEvent", err, jni.JNI_FALSE);
1758 events.settle(allocator, calendar.create_resolve_global, payload) catch |err| return fellThrough("createCalendarEvent", err, jni.JNI_FALSE);
17271759 return jni.JNI_TRUE;
17281760}
17291761
@@ -1763,19 +1795,19 @@ fn nativeDbExecute(
17631795 const allocator = arena.allocator();
17641796
17651797 var parsed: std.json.Parsed(std.json.Value) = undefined;
1766 const call = (dbCall(j, allocator, sql, params_json, &parsed) catch return jni.JNI_FALSE) orelse
1798 const call = (dbCall(j, allocator, sql, params_json, &parsed) catch |err| return fellThrough("dbExecute", err, jni.JNI_FALSE)) orelse
17671799 return jni.JNI_FALSE;
17681800
17691801 const rows_affected = db.execute(j, allocator, database, call.sql, call.args) catch |err| {
17701802 // The shim catches and rejects with the exception's message; the
17711803 // throwable was described to logcat and cleared by `Jni.check` before
17721804 // this point, so the error name is what is left to say.
1773 calendar.rejectOn(allocator, db.exec_reject_global, @errorName(err)) catch {};
1805 calendar.rejectOn(allocator, db.exec_reject_global, @errorName(err)) catch |inner| undelivered("dbExecute", inner);
17741806 return jni.JNI_TRUE;
17751807 };
17761808
1777 const payload = db.renderExecResult(allocator, rows_affected) catch return jni.JNI_FALSE;
1778 events.settle(allocator, db.exec_resolve_global, payload) catch return jni.JNI_FALSE;
1809 const payload = db.renderExecResult(allocator, rows_affected) catch |err| return fellThrough("dbExecute", err, jni.JNI_FALSE);
1810 events.settle(allocator, db.exec_resolve_global, payload) catch |err| return fellThrough("dbExecute", err, jni.JNI_FALSE);
17791811 return jni.JNI_TRUE;
17801812}
17811813
@@ -1793,17 +1825,17 @@ fn nativeDbQuery(
17931825 const allocator = arena.allocator();
17941826
17951827 var parsed: std.json.Parsed(std.json.Value) = undefined;
1796 const call = (dbCall(j, allocator, sql, params_json, &parsed) catch return jni.JNI_FALSE) orelse
1828 const call = (dbCall(j, allocator, sql, params_json, &parsed) catch |err| return fellThrough("dbQuery", err, jni.JNI_FALSE)) orelse
17971829 return jni.JNI_FALSE;
17981830
17991831 var payload: std.ArrayListUnmanaged(u8) = .empty;
18001832 defer payload.deinit(allocator);
18011833 db.query(j, allocator, database, call.sql, call.args, &payload) catch |err| {
1802 calendar.rejectOn(allocator, db.query_reject_global, @errorName(err)) catch {};
1834 calendar.rejectOn(allocator, db.query_reject_global, @errorName(err)) catch |inner| undelivered("dbQuery", inner);
18031835 return jni.JNI_TRUE;
18041836 };
18051837
1806 events.settle(allocator, db.query_resolve_global, payload.items) catch return jni.JNI_FALSE;
1838 events.settle(allocator, db.query_resolve_global, payload.items) catch |err| return fellThrough("dbQuery", err, jni.JNI_FALSE);
18071839 return jni.JNI_TRUE;
18081840}
18091841
@@ -1821,17 +1853,17 @@ fn nativeSetSharedItem(
18211853 defer arena.deinit();
18221854 const allocator = arena.allocator();
18231855
1824 const key_text = j.stringToUtf8(allocator, key) catch return jni.JNI_FALSE;
1825 const value_text = j.stringToUtf8(allocator, value) catch return jni.JNI_FALSE;
1826 const group_text = j.stringToUtf8(allocator, group) catch return jni.JNI_FALSE;
1856 const key_text = j.stringToUtf8(allocator, key) catch |err| return fellThrough("setSharedItem", err, jni.JNI_FALSE);
1857 const value_text = j.stringToUtf8(allocator, value) catch |err| return fellThrough("setSharedItem", err, jni.JNI_FALSE);
1858 const group_text = j.stringToUtf8(allocator, group) catch |err| return fellThrough("setSharedItem", err, jni.JNI_FALSE);
18271859
18281860 shareditem.set(j, allocator, activity, group_text, key_text, value_text) catch |err| {
1829 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch {};
1861 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch |inner| undelivered("setSharedItem", inner);
18301862 return jni.JNI_TRUE;
18311863 };
18321864
1833 const payload = shareditem.mutationPayload(allocator, .set, key_text) catch return jni.JNI_FALSE;
1834 events.settle(allocator, shareditem.resolve_global, payload) catch return jni.JNI_FALSE;
1865 const payload = shareditem.mutationPayload(allocator, .set, key_text) catch |err| return fellThrough("setSharedItem", err, jni.JNI_FALSE);
1866 events.settle(allocator, shareditem.resolve_global, payload) catch |err| return fellThrough("setSharedItem", err, jni.JNI_FALSE);
18351867 return jni.JNI_TRUE;
18361868}
18371869
@@ -1852,16 +1884,16 @@ fn nativeGetSharedItem(
18521884 defer arena.deinit();
18531885 const allocator = arena.allocator();
18541886
1855 const key_text = j.stringToUtf8(allocator, key) catch return jni.JNI_FALSE;
1856 const group_text = j.stringToUtf8(allocator, group) catch return jni.JNI_FALSE;
1887 const key_text = j.stringToUtf8(allocator, key) catch |err| return fellThrough("getSharedItem", err, jni.JNI_FALSE);
1888 const group_text = j.stringToUtf8(allocator, group) catch |err| return fellThrough("getSharedItem", err, jni.JNI_FALSE);
18571889
18581890 const value = shareditem.get(j, allocator, activity, group_text, key_text) catch |err| {
1859 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch {};
1891 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch |inner| undelivered("getSharedItem", inner);
18601892 return jni.JNI_TRUE;
18611893 };
18621894
1863 const payload = shareditem.valuePayload(allocator, key_text, value) catch return jni.JNI_FALSE;
1864 events.settle(allocator, shareditem.resolve_global, payload) catch return jni.JNI_FALSE;
1895 const payload = shareditem.valuePayload(allocator, key_text, value) catch |err| return fellThrough("getSharedItem", err, jni.JNI_FALSE);
1896 events.settle(allocator, shareditem.resolve_global, payload) catch |err| return fellThrough("getSharedItem", err, jni.JNI_FALSE);
18651897 return jni.JNI_TRUE;
18661898}
18671899
@@ -1878,16 +1910,16 @@ fn nativeRemoveSharedItem(
18781910 defer arena.deinit();
18791911 const allocator = arena.allocator();
18801912
1881 const key_text = j.stringToUtf8(allocator, key) catch return jni.JNI_FALSE;
1882 const group_text = j.stringToUtf8(allocator, group) catch return jni.JNI_FALSE;
1913 const key_text = j.stringToUtf8(allocator, key) catch |err| return fellThrough("removeSharedItem", err, jni.JNI_FALSE);
1914 const group_text = j.stringToUtf8(allocator, group) catch |err| return fellThrough("removeSharedItem", err, jni.JNI_FALSE);
18831915
18841916 shareditem.remove(j, allocator, activity, group_text, key_text) catch |err| {
1885 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch {};
1917 calendar.rejectOn(allocator, shareditem.reject_global, @errorName(err)) catch |inner| undelivered("removeSharedItem", inner);
18861918 return jni.JNI_TRUE;
18871919 };
18881920
1889 const payload = shareditem.mutationPayload(allocator, .remove, key_text) catch return jni.JNI_FALSE;
1890 events.settle(allocator, shareditem.resolve_global, payload) catch return jni.JNI_FALSE;
1921 const payload = shareditem.mutationPayload(allocator, .remove, key_text) catch |err| return fellThrough("removeSharedItem", err, jni.JNI_FALSE);
1922 events.settle(allocator, shareditem.resolve_global, payload) catch |err| return fellThrough("removeSharedItem", err, jni.JNI_FALSE);
18911923 return jni.JNI_TRUE;
18921924}
18931925
@@ -1916,7 +1948,7 @@ fn nativeGetContacts(
19161948 std.log.warn("craft: getContacts fell through to the shim ({s})", .{@errorName(err)});
19171949 return jni.JNI_FALSE;
19181950 };
1919 events.settle(allocator, contacts.reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
1951 events.settle(allocator, contacts.reject_global, "\"Permission denied\"") catch |err| return fellThrough("getContacts", err, jni.JNI_FALSE);
19201952 return jni.JNI_TRUE;
19211953 }
19221954
@@ -1927,7 +1959,7 @@ fn nativeGetContacts(
19271959 return jni.JNI_FALSE;
19281960 };
19291961
1930 events.settle(allocator, contacts.resolve_global, payload.items) catch return jni.JNI_FALSE;
1962 events.settle(allocator, contacts.resolve_global, payload.items) catch |err| return fellThrough("getContacts", err, jni.JNI_FALSE);
19311963 return jni.JNI_TRUE;
19321964}
19331965
@@ -1957,11 +1989,11 @@ fn nativeAddContact(
19571989 std.log.warn("craft: addContact fell through to the shim ({s})", .{@errorName(err)});
19581990 return jni.JNI_FALSE;
19591991 };
1960 events.settle(allocator, contacts.add_reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
1992 events.settle(allocator, contacts.add_reject_global, "\"Permission denied\"") catch |err| return fellThrough("addContact", err, jni.JNI_FALSE);
19611993 return jni.JNI_TRUE;
19621994 }
19631995
1964 const text = j.stringToUtf8(allocator, contact_json) catch return jni.JNI_FALSE;
1996 const text = j.stringToUtf8(allocator, contact_json) catch |err| return fellThrough("addContact", err, jni.JNI_FALSE);
19651997 var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch
19661998 return jni.JNI_FALSE;
19671999 defer parsed.deinit();
@@ -1969,14 +2001,14 @@ fn nativeAddContact(
19692001 const contact = contacts.parseNewContact(parsed.value) orelse return jni.JNI_FALSE;
19702002
19712003 const id = contacts.addContact(j, allocator, activity, contact) catch |err| {
1972 calendar.rejectOn(allocator, contacts.add_reject_global, @errorName(err)) catch {};
2004 calendar.rejectOn(allocator, contacts.add_reject_global, @errorName(err)) catch |inner| undelivered("addContact", inner);
19732005 return jni.JNI_TRUE;
19742006 };
19752007
19762008 var buf: [24]u8 = undefined;
1977 const digits = std.fmt.bufPrint(&buf, "{d}", .{id}) catch return jni.JNI_FALSE;
1978 const payload = calendar.jsonString(allocator, digits) catch return jni.JNI_FALSE;
1979 events.settle(allocator, contacts.add_resolve_global, payload) catch return jni.JNI_FALSE;
2009 const digits = std.fmt.bufPrint(&buf, "{d}", .{id}) catch |err| return fellThrough("addContact", err, jni.JNI_FALSE);
2010 const payload = calendar.jsonString(allocator, digits) catch |err| return fellThrough("addContact", err, jni.JNI_FALSE);
2011 events.settle(allocator, contacts.add_resolve_global, payload) catch |err| return fellThrough("addContact", err, jni.JNI_FALSE);
19802012 return jni.JNI_TRUE;
19812013}
19822014
@@ -2009,20 +2041,20 @@ fn nativeUpdateWidget(
20092041 defer arena.deinit();
20102042 const allocator = arena.allocator();
20112043
2012 const text = j.stringToUtf8(allocator, data_json) catch return jni.JNI_FALSE;
2044 const text = j.stringToUtf8(allocator, data_json) catch |err| return fellThrough("updateWidget", err, jni.JNI_FALSE);
20132045 var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch
20142046 return jni.JNI_FALSE;
20152047 defer parsed.deinit();
20162048
20172049 const update = widgets.parseUpdate(parsed.value) orelse return jni.JNI_FALSE;
2018 const action_text = j.stringToUtf8(allocator, action) catch return jni.JNI_FALSE;
2050 const action_text = j.stringToUtf8(allocator, action) catch |err| return fellThrough("updateWidget", err, jni.JNI_FALSE);
20192051
20202052 widgets.writeUpdate(j, allocator, activity, update) catch |err| {
2021 calendar.rejectOn(allocator, widgets.reject_global, @errorName(err)) catch {};
2053 calendar.rejectOn(allocator, widgets.reject_global, @errorName(err)) catch |inner| undelivered("updateWidget", inner);
20222054 return jni.JNI_TRUE;
20232055 };
20242056 widgets.broadcast(j, allocator, activity, action_text) catch |err| {
2025 calendar.rejectOn(allocator, widgets.reject_global, @errorName(err)) catch {};
2057 calendar.rejectOn(allocator, widgets.reject_global, @errorName(err)) catch |inner| undelivered("updateWidget", inner);
20262058 return jni.JNI_TRUE;
20272059 };
20282060
@@ -2047,10 +2079,10 @@ fn nativeReloadWidgets(
20472079 defer arena.deinit();
20482080 const allocator = arena.allocator();
20492081
2050 const action_text = j.stringToUtf8(allocator, action) catch return jni.JNI_FALSE;
2082 const action_text = j.stringToUtf8(allocator, action) catch |err| return fellThrough("reloadWidgets", err, jni.JNI_FALSE);
20512083
20522084 widgets.broadcast(j, allocator, activity, action_text) catch |err| {
2053 calendar.rejectOn(allocator, widgets.reject_global, @errorName(err)) catch {};
2085 calendar.rejectOn(allocator, widgets.reject_global, @errorName(err)) catch |inner| undelivered("reloadWidgets", inner);
20542086 return jni.JNI_TRUE;
20552087 };
20562088
@@ -2079,11 +2111,11 @@ fn nativeSetShortcuts(
20792111 if (sdk < shortcuts.n_mr1) {
20802112 const payload = shortcuts.jsonString(allocator, shortcuts.unsupported_message) catch
20812113 return jni.JNI_FALSE;
2082 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
2114 events.settle(allocator, shortcuts.reject_global, payload) catch |err| return fellThrough("setShortcuts", err, jni.JNI_FALSE);
20832115 return jni.JNI_TRUE;
20842116 }
20852117
2086 const text = j.stringToUtf8(allocator, shortcuts_json) catch return jni.JNI_FALSE;
2118 const text = j.stringToUtf8(allocator, shortcuts_json) catch |err| return fellThrough("setShortcuts", err, jni.JNI_FALSE);
20872119 var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch
20882120 return jni.JNI_FALSE;
20892121 defer parsed.deinit();
@@ -2094,19 +2126,19 @@ fn nativeSetShortcuts(
20942126 const list = shortcuts.parseShortcuts(allocator, parsed.value) catch |err| {
20952127 const payload = shortcuts.jsonString(allocator, @errorName(err)) catch
20962128 return jni.JNI_FALSE;
2097 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
2129 events.settle(allocator, shortcuts.reject_global, payload) catch |inner| return fellThrough("setShortcuts", inner, jni.JNI_FALSE);
20982130 return jni.JNI_TRUE;
20992131 } orelse return jni.JNI_FALSE;
21002132
21012133 shortcuts.set(j, allocator, activity, list) catch |err| {
21022134 const payload = shortcuts.jsonString(allocator, @errorName(err)) catch
21032135 return jni.JNI_FALSE;
2104 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
2136 events.settle(allocator, shortcuts.reject_global, payload) catch |inner| return fellThrough("setShortcuts", inner, jni.JNI_FALSE);
21052137 return jni.JNI_TRUE;
21062138 };
21072139
2108 const payload = shortcuts.setPayload(allocator, list.len) catch return jni.JNI_FALSE;
2109 events.settle(allocator, shortcuts.resolve_global, payload) catch return jni.JNI_FALSE;
2140 const payload = shortcuts.setPayload(allocator, list.len) catch |err| return fellThrough("setShortcuts", err, jni.JNI_FALSE);
2141 events.settle(allocator, shortcuts.resolve_global, payload) catch |err| return fellThrough("setShortcuts", err, jni.JNI_FALSE);
21102142 return jni.JNI_TRUE;
21112143}
21122144
@@ -2133,12 +2165,12 @@ fn nativeClearShortcuts(
21332165 shortcuts.clear(j, activity) catch |err| {
21342166 const payload = shortcuts.jsonString(allocator, @errorName(err)) catch
21352167 return jni.JNI_FALSE;
2136 events.settle(allocator, shortcuts.reject_global, payload) catch return jni.JNI_FALSE;
2168 events.settle(allocator, shortcuts.reject_global, payload) catch |inner| return fellThrough("clearShortcuts", inner, jni.JNI_FALSE);
21372169 return jni.JNI_TRUE;
21382170 };
21392171 }
21402172
2141 events.settle(allocator, shortcuts.resolve_global, shortcuts.cleared_result) catch return jni.JNI_FALSE;
2173 events.settle(allocator, shortcuts.resolve_global, shortcuts.cleared_result) catch |err| return fellThrough("clearShortcuts", err, jni.JNI_FALSE);
21422174 return jni.JNI_TRUE;
21432175}
21442176
@@ -2160,7 +2192,7 @@ fn nativeScheduleNotification(
21602192 defer arena.deinit();
21612193 const allocator = arena.allocator();
21622194
2163 const text = j.stringToUtf8(allocator, notification_json) catch return jni.JNI_FALSE;
2195 const text = j.stringToUtf8(allocator, notification_json) catch |err| return fellThrough("scheduleNotification", err, jni.JNI_FALSE);
21642196
21652197 // `JSONObject(notificationJson)` throwing is the shim's first catch, so a
21662198 // payload this cannot parse goes back rather than being rejected here.
@@ -2168,25 +2200,25 @@ fn nativeScheduleNotification(
21682200 return jni.JNI_FALSE;
21692201 defer parsed.deinit();
21702202
2171 const default_id = notifications.defaultId(j, allocator) catch return jni.JNI_FALSE;
2203 const default_id = notifications.defaultId(j, allocator) catch |err| return fellThrough("scheduleNotification", err, jni.JNI_FALSE);
21722204 const notification = notifications.parseNotification(parsed.value, default_id) orelse
21732205 return jni.JNI_FALSE;
21742206
21752207 if (!notifications.servesImmediately(notification)) return jni.JNI_FALSE;
21762208
21772209 notifications.ensureChannel(j, activity) catch |err| {
2178 calendar.rejectOn(allocator, notifications.schedule_reject_global, @errorName(err)) catch {};
2210 calendar.rejectOn(allocator, notifications.schedule_reject_global, @errorName(err)) catch |inner| undelivered("scheduleNotification", inner);
21792211 return jni.JNI_TRUE;
21802212 };
21812213
21822214 _ = notifications.post(j, allocator, activity, notification) catch |err| {
2183 calendar.rejectOn(allocator, notifications.schedule_reject_global, @errorName(err)) catch {};
2215 calendar.rejectOn(allocator, notifications.schedule_reject_global, @errorName(err)) catch |inner| undelivered("scheduleNotification", inner);
21842216 return jni.JNI_TRUE;
21852217 };
21862218
21872219 // The id, not the hash: the page sent a string and gets the same string
21882220 // back, which is what it needs to cancel with later.
2189 const payload = calendar.jsonString(allocator, notification.id) catch return jni.JNI_FALSE;
2221 const payload = calendar.jsonString(allocator, notification.id) catch |err| return fellThrough("scheduleNotification", err, jni.JNI_FALSE);
21902222 events.settle(allocator, notifications.schedule_resolve_global, payload) catch
21912223 return jni.JNI_FALSE;
21922224 return jni.JNI_TRUE;
@@ -2237,7 +2269,7 @@ fn nativeLockOrientation(
22372269 var arena = std.heap.ArenaAllocator.init(backing);
22382270 defer arena.deinit();
22392271
2240 const text = j.stringToUtf8(arena.allocator(), name) catch return jni.JNI_FALSE;
2272 const text = j.stringToUtf8(arena.allocator(), name) catch |err| return fellThrough("lockOrientation", err, jni.JNI_FALSE);
22412273
22422274 screen.apply(j, activity, screen.modeFor(text)) catch |err| {
22432275 std.log.warn("craft: lockOrientation fell through to the shim ({s})", .{@errorName(err)});
@@ -2298,18 +2330,18 @@ fn nativeDownloadFile(
22982330 defer arena.deinit();
22992331 const allocator = arena.allocator();
23002332
2301 const url_text = j.stringToUtf8(allocator, url) catch return jni.JNI_FALSE;
2302 const name_text = j.stringToUtf8(allocator, filename) catch return jni.JNI_FALSE;
2333 const url_text = j.stringToUtf8(allocator, url) catch |err| return fellThrough("downloadFile", err, jni.JNI_FALSE);
2334 const name_text = j.stringToUtf8(allocator, filename) catch |err| return fellThrough("downloadFile", err, jni.JNI_FALSE);
23032335
23042336 const id = files.download(j, allocator, activity, url_text, name_text) catch |err| {
2305 calendar.rejectOn(allocator, files.download_reject_global, @errorName(err)) catch {};
2337 calendar.rejectOn(allocator, files.download_reject_global, @errorName(err)) catch |inner| undelivered("downloadFile", inner);
23062338 return jni.JNI_TRUE;
23072339 };
23082340
23092341 var buf: [24]u8 = undefined;
2310 const digits = std.fmt.bufPrint(&buf, "{d}", .{id}) catch return jni.JNI_FALSE;
2311 const payload = files.jsonString(allocator, digits) catch return jni.JNI_FALSE;
2312 events.settle(allocator, files.download_resolve_global, payload) catch return jni.JNI_FALSE;
2342 const digits = std.fmt.bufPrint(&buf, "{d}", .{id}) catch |err| return fellThrough("downloadFile", err, jni.JNI_FALSE);
2343 const payload = files.jsonString(allocator, digits) catch |err| return fellThrough("downloadFile", err, jni.JNI_FALSE);
2344 events.settle(allocator, files.download_resolve_global, payload) catch |err| return fellThrough("downloadFile", err, jni.JNI_FALSE);
23132345 return jni.JNI_TRUE;
23142346}
23152347
@@ -2326,20 +2358,20 @@ fn nativeSaveFile(
23262358 defer arena.deinit();
23272359 const allocator = arena.allocator();
23282360
2329 const data_text = j.stringToUtf8(allocator, data) catch return jni.JNI_FALSE;
2330 const name_text = j.stringToUtf8(allocator, filename) catch return jni.JNI_FALSE;
2361 const data_text = j.stringToUtf8(allocator, data) catch |err| return fellThrough("saveFile", err, jni.JNI_FALSE);
2362 const name_text = j.stringToUtf8(allocator, filename) catch |err| return fellThrough("saveFile", err, jni.JNI_FALSE);
23312363
23322364 const plan = files.planFor(data_text) catch {
2333 calendar.rejectOn(allocator, files.save_reject_global, files.malformed_data_url) catch {};
2365 calendar.rejectOn(allocator, files.save_reject_global, files.malformed_data_url) catch |err| undelivered("saveFile", err);
23342366 return jni.JNI_TRUE;
23352367 };
23362368 const path = files.save(j, allocator, activity, name_text, plan) catch |err| {
2337 calendar.rejectOn(allocator, files.save_reject_global, @errorName(err)) catch {};
2369 calendar.rejectOn(allocator, files.save_reject_global, @errorName(err)) catch |inner| undelivered("saveFile", inner);
23382370 return jni.JNI_TRUE;
23392371 };
23402372
2341 const payload = files.jsonString(allocator, path) catch return jni.JNI_FALSE;
2342 events.settle(allocator, files.save_resolve_global, payload) catch return jni.JNI_FALSE;
2373 const payload = files.jsonString(allocator, path) catch |err| return fellThrough("saveFile", err, jni.JNI_FALSE);
2374 events.settle(allocator, files.save_resolve_global, payload) catch |err| return fellThrough("saveFile", err, jni.JNI_FALSE);
23432375 return jni.JNI_TRUE;
23442376}
23452377
@@ -2364,7 +2396,7 @@ fn nativeGetLocationRecordingState(
23642396
23652397 // A line the shim would drop and this cannot parse: hand the whole read
23662398 // back rather than disagreeing about how many samples there are.
2367 const locations = (locationstore.renderLocations(allocator, contents) catch return null) orelse
2399 const locations = (locationstore.renderLocations(allocator, contents) catch |err| return fellThrough("getLocationRecordingState", err, @as(jni.jstring, null))) orelse
23682400 return null;
23692401
23702402 const state = locationstore.readState(j, allocator, activity, locations.count) catch |err| {
@@ -2372,8 +2404,8 @@ fn nativeGetLocationRecordingState(
23722404 return null;
23732405 };
23742406
2375 const json = locationstore.renderState(allocator, state) catch return null;
2376 return j.newStringUtf8(allocator, json) catch null;
2407 const json = locationstore.renderState(allocator, state) catch |err| return fellThrough("getLocationRecordingState", err, @as(jni.jstring, null));
2408 return j.newStringUtf8(allocator, json) catch |err| fellThrough("getLocationRecordingState", err, @as(jni.jstring, null));
23772409}
23782410
23792411/// `nativeReadLocationRecording(activity)`.
@@ -2392,9 +2424,9 @@ fn nativeReadLocationRecording(
23922424 return null;
23932425 };
23942426
2395 const locations = (locationstore.renderLocations(allocator, contents) catch return null) orelse
2427 const locations = (locationstore.renderLocations(allocator, contents) catch |err| return fellThrough("readLocationRecording", err, @as(jni.jstring, null))) orelse
23962428 return null;
2397 return j.newStringUtf8(allocator, locations.json) catch null;
2429 return j.newStringUtf8(allocator, locations.json) catch |err| fellThrough("readLocationRecording", err, @as(jni.jstring, null));
23982430}
23992431
24002432/// The current state as a Java String, or null if any part of the read
@@ -2405,17 +2437,17 @@ fn recordingStateString(
24052437 activity: jni.jobject,
24062438 include_locations: bool,
24072439) jni.jstring {
2408 const contents = locationstore.readFile(j, allocator, activity) catch return null;
2409 const locations = (locationstore.renderLocations(allocator, contents) catch return null) orelse
2440 const contents = locationstore.readFile(j, allocator, activity) catch |err| return fellThrough("recordingStateString", err, @as(jni.jstring, null));
2441 const locations = (locationstore.renderLocations(allocator, contents) catch |err| return fellThrough("recordingStateString", err, @as(jni.jstring, null))) orelse
24102442 return null;
2411 const state = locationstore.readState(j, allocator, activity, locations.count) catch return null;
2443 const state = locationstore.readState(j, allocator, activity, locations.count) catch |err| return fellThrough("recordingStateString", err, @as(jni.jstring, null));
24122444
24132445 const json = locationstore.renderStateWith(
24142446 allocator,
24152447 state,
24162448 if (include_locations) locations.json else null,
2417 ) catch return null;
2418 return j.newStringUtf8(allocator, json) catch null;
2449 ) catch |err| return fellThrough("recordingStateString", err, @as(jni.jstring, null));
2450 return j.newStringUtf8(allocator, json) catch |err| fellThrough("recordingStateString", err, @as(jni.jstring, null));
24192451}
24202452
24212453/// `nativeStartLocationRecording(activity)`.
@@ -2436,16 +2468,16 @@ fn nativeStartLocationRecording(
24362468 // A different object from "never recorded": this path builds a fresh
24372469 // JSONObject with an explicit JSONObject.NULL id, so the key is
24382470 // present and null rather than absent.
2439 const json = locationstore.renderDenied(allocator) catch return null;
2440 return j.newStringUtf8(allocator, json) catch null;
2471 const json = locationstore.renderDenied(allocator) catch |err| return fellThrough("startLocationRecording", err, @as(jni.jstring, null));
2472 return j.newStringUtf8(allocator, json) catch |err| fellThrough("startLocationRecording", err, @as(jni.jstring, null));
24412473 }
24422474
2443 const id = locationstore.newRecordingId(j, allocator) catch return null;
2444 const started_at = locationstore.nowMillis(j) catch return null;
2445 locationstore.startStore(j, allocator, activity, id, started_at) catch return null;
2475 const id = locationstore.newRecordingId(j, allocator) catch |err| return fellThrough("startLocationRecording", err, @as(jni.jstring, null));
2476 const started_at = locationstore.nowMillis(j) catch |err| return fellThrough("startLocationRecording", err, @as(jni.jstring, null));
2477 locationstore.startStore(j, allocator, activity, id, started_at) catch |err| return fellThrough("startLocationRecording", err, @as(jni.jstring, null));
24462478
24472479 // Kotlin names the service class, because its package is templated.
2448 startRecordingService(j, activity) catch return null;
2480 startRecordingService(j, activity) catch |err| return fellThrough("startLocationRecording", err, @as(jni.jstring, null));
24492481
24502482 return recordingStateString(j, allocator, activity, false);
24512483}
@@ -2464,14 +2496,14 @@ fn nativeStopLocationRecording(
24642496 defer arena.deinit();
24652497 const allocator = arena.allocator();
24662498
2467 locationstore.stopStore(j, allocator, activity) catch return null;
2499 locationstore.stopStore(j, allocator, activity) catch |err| return fellThrough("stopLocationRecording", err, @as(jni.jstring, null));
24682500
24692501 // Declining after the store write is safe: `stop` is idempotent, so the
24702502 // shim runs the whole method again and reaches `stopService` itself.
24712503 const result = recordingStateString(j, allocator, activity, true);
24722504 if (result == null) return null;
24732505
2474 stopRecordingService(j, activity) catch return null;
2506 stopRecordingService(j, activity) catch |err| return fellThrough("stopLocationRecording", err, @as(jni.jstring, null));
24752507 return result;
24762508}
24772509
@@ -2486,7 +2518,7 @@ fn nativePauseLocationRecording(
24862518 defer arena.deinit();
24872519 const allocator = arena.allocator();
24882520
2489 locationstore.setPaused(j, allocator, activity, true) catch return null;
2521 locationstore.setPaused(j, allocator, activity, true) catch |err| return fellThrough("pauseLocationRecording", err, @as(jni.jstring, null));
24902522 return recordingStateString(j, allocator, activity, false);
24912523}
24922524
@@ -2505,10 +2537,10 @@ fn nativeResumeLocationRecording(
25052537 defer arena.deinit();
25062538 const allocator = arena.allocator();
25072539
2508 locationstore.setPaused(j, allocator, activity, false) catch return null;
2540 locationstore.setPaused(j, allocator, activity, false) catch |err| return fellThrough("resumeLocationRecording", err, @as(jni.jstring, null));
25092541
2510 const active = locationstore.isActive(j, allocator, activity) catch return null;
2511 if (active) startRecordingService(j, activity) catch return null;
2542 const active = locationstore.isActive(j, allocator, activity) catch |err| return fellThrough("resumeLocationRecording", err, @as(jni.jstring, null));
2543 if (active) startRecordingService(j, activity) catch |err| return fellThrough("resumeLocationRecording", err, @as(jni.jstring, null));
25122544
25132545 return recordingStateString(j, allocator, activity, false);
25142546}
@@ -2540,9 +2572,9 @@ fn nativeNetworkChanged(
25402572 defer arena.deinit();
25412573 const allocator = arena.allocator();
25422574
2543 const status = network.read(j, activity) catch return;
2544 const json = network.render(allocator, status) catch return;
2545 events.settle(allocator, network.change_global, json) catch {};
2575 const status = network.read(j, activity) catch |err| return failedWithoutFallback("networkChanged", err);
2576 const json = network.render(allocator, status) catch |err| return failedWithoutFallback("networkChanged", err);
2577 events.settle(allocator, network.change_global, json) catch |err| undelivered("networkChanged", err);
25462578}
25472579
25482580/// `nativeStartNetworkMonitoring(activity)`.
@@ -2613,12 +2645,12 @@ fn nativeAppStateEvent(
26132645 defer arena.deinit();
26142646 const allocator = arena.allocator();
26152647
2616 const name = j.stringToUtf8(allocator, event_name) catch return;
2648 const name = j.stringToUtf8(allocator, event_name) catch |err| return failedWithoutFallback("appStateEvent", err);
26172649
26182650 // Null covers both "this event carries no state" and "it carries the one
26192651 // already held", and the shim announces neither.
26202652 const changed = appstate.apply(name) orelse return;
2621 appstate.announce(allocator, changed) catch {};
2653 appstate.announce(allocator, changed) catch |err| undelivered("appStateEvent", err);
26222654}
26232655
26242656/// `nativeStartAppStateMonitoring(activity)`.
@@ -2667,7 +2699,7 @@ fn nativeGetAppState(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jstring {
26672699 var arena = std.heap.ArenaAllocator.init(backing);
26682700 defer arena.deinit();
26692701
2670 return j.newStringUtf8(arena.allocator(), appstate.state().name()) catch null;
2702 return j.newStringUtf8(arena.allocator(), appstate.state().name()) catch |err| fellThrough("getAppState", err, @as(jni.jstring, null));
26712703}
26722704
26732705/// `nativeBluetoothDevice(address, name, rssi)` — one scan result.
@@ -2683,16 +2715,16 @@ fn nativeBluetoothDevice(
26832715 defer arena.deinit();
26842716 const allocator = arena.allocator();
26852717
2686 const address_text = j.stringToUtf8(allocator, address) catch return;
2718 const address_text = j.stringToUtf8(allocator, address) catch |err| return failedWithoutFallback("bluetoothDevice", err);
26872719
26882720 // Null is a device advertising no name, which is the common case rather
26892721 // than the edge one — `bluetooth.announce` applies the shim's "Unknown".
26902722 const name_text: ?[]const u8 = if (name == null)
26912723 null
26922724 else
2693 j.stringToUtf8(allocator, name) catch return;
2725 j.stringToUtf8(allocator, name) catch |err| return failedWithoutFallback("bluetoothDevice", err);
26942726
2695 bluetooth.announce(allocator, address_text, name_text, rssi) catch {};
2727 bluetooth.announce(allocator, address_text, name_text, rssi) catch |err| undelivered("bluetoothDevice", err);
26962728}
26972729
26982730/// `nativeStartBluetoothScan(activity)`.
@@ -2723,7 +2755,7 @@ fn nativeStartBluetoothScan(
27232755 std.log.warn("craft: startBluetoothScan fell through to the shim ({s})", .{@errorName(err)});
27242756 return jni.JNI_FALSE;
27252757 };
2726 calendar.rejectOn(allocator, bluetooth.reject_global, "Permission denied") catch {};
2758 calendar.rejectOn(allocator, bluetooth.reject_global, "Permission denied") catch |err| undelivered("startBluetoothScan", err);
27272759 return jni.JNI_TRUE;
27282760 }
27292761
@@ -2733,11 +2765,11 @@ fn nativeStartBluetoothScan(
27332765 };
27342766
27352767 if (bluetooth.startError(start_status)) |message| {
2736 calendar.rejectOn(allocator, bluetooth.reject_global, message) catch return jni.JNI_FALSE;
2768 calendar.rejectOn(allocator, bluetooth.reject_global, message) catch |err| return fellThrough("startBluetoothScan", err, jni.JNI_FALSE);
27372769 return jni.JNI_TRUE;
27382770 }
27392771
2740 events.settle(allocator, bluetooth.resolve_global, "true") catch return jni.JNI_FALSE;
2772 events.settle(allocator, bluetooth.resolve_global, "true") catch |err| return fellThrough("startBluetoothScan", err, jni.JNI_FALSE);
27412773 return jni.JNI_TRUE;
27422774}
27432775
@@ -2787,15 +2819,15 @@ fn nativeMotionSample(
27872819 // `Float.toString` is not a format string — see `android_json_number`.
27882820 var printed: [6][]const u8 = undefined;
27892821 inline for (0..3) |i| {
2790 printed[i] = json_number.fromFloat(j, allocator, acceleration[i]) catch return;
2791 printed[i + 3] = json_number.fromFloat(j, allocator, rotation[i]) catch return;
2822 printed[i] = json_number.fromFloat(j, allocator, acceleration[i]) catch |err| return failedWithoutFallback("motionSample", err);
2823 printed[i + 3] = json_number.fromFloat(j, allocator, rotation[i]) catch |err| return failedWithoutFallback("motionSample", err);
27922824 }
27932825
27942826 motion.announce(
27952827 allocator,
27962828 .{ printed[0], printed[1], printed[2] },
27972829 .{ printed[3], printed[4], printed[5] },
2798 ) catch {};
2830 ) catch |err| undelivered("motionSample", err);
27992831}
28002832
28012833/// `nativeStartMotionUpdates(activity, intervalMs)`.
@@ -2900,20 +2932,20 @@ fn nativeLocationResult(
29002932
29012933 var timestamp: [24]u8 = undefined;
29022934 const printed: position.Printed = .{
2903 .latitude = json_number.fromDouble(j, allocator, latitude) catch return,
2904 .longitude = json_number.fromDouble(j, allocator, longitude) catch return,
2905 .accuracy = json_number.fromDouble(j, allocator, accuracy) catch return,
2906 .altitude = json_number.fromDouble(j, allocator, altitude) catch return,
2907 .speed = json_number.fromDouble(j, allocator, speed) catch return,
2908 .heading = json_number.fromDouble(j, allocator, bearing) catch return,
2935 .latitude = json_number.fromDouble(j, allocator, latitude) catch |err| return failedWithoutFallback("locationResult", err),
2936 .longitude = json_number.fromDouble(j, allocator, longitude) catch |err| return failedWithoutFallback("locationResult", err),
2937 .accuracy = json_number.fromDouble(j, allocator, accuracy) catch |err| return failedWithoutFallback("locationResult", err),
2938 .altitude = json_number.fromDouble(j, allocator, altitude) catch |err| return failedWithoutFallback("locationResult", err),
2939 .speed = json_number.fromDouble(j, allocator, speed) catch |err| return failedWithoutFallback("locationResult", err),
2940 .heading = json_number.fromDouble(j, allocator, bearing) catch |err| return failedWithoutFallback("locationResult", err),
29092941 // A long, not a double: `put(String, long)` boxes a Long, and
29102942 // `numberToString` prints one with `Long.toString` — which needs no
29112943 // JNI call, because there is no float format to reproduce.
2912 .timestamp = std.fmt.bufPrint(&timestamp, "{d}", .{time}) catch return,
2944 .timestamp = std.fmt.bufPrint(&timestamp, "{d}", .{time}) catch |err| return failedWithoutFallback("locationResult", err),
29132945 };
29142946
2915 const json = position.renderPosition(allocator, printed) catch return;
2916 events.settle(allocator, position.resolve_global, json) catch {};
2947 const json = position.renderPosition(allocator, printed) catch |err| return failedWithoutFallback("locationResult", err);
2948 events.settle(allocator, position.resolve_global, json) catch |err| undelivered("locationResult", err);
29172949}
29182950
29192951/// `nativeLocationFailed(message)` — the failure listener.
@@ -2932,10 +2964,10 @@ fn nativeLocationFailed(
29322964 const text: []const u8 = if (message == null)
29332965 "null"
29342966 else
2935 j.stringToUtf8(allocator, message) catch return;
2967 j.stringToUtf8(allocator, message) catch |err| return failedWithoutFallback("locationFailed", err);
29362968
2937 const json = position.renderRejection(allocator, position.code_client_failure, text) catch return;
2938 events.settle(allocator, position.reject_global, json) catch {};
2969 const json = position.renderRejection(allocator, position.code_client_failure, text) catch |err| return failedWithoutFallback("locationFailed", err);
2970 events.settle(allocator, position.reject_global, json) catch |err| undelivered("locationFailed", err);
29392971}
29402972
29412973/// `nativeGetCurrentPosition(activity)`.
@@ -2967,8 +2999,8 @@ fn nativeGetCurrentPosition(
29672999 allocator,
29683000 position.code_permission_denied,
29693001 position.permission_denied_message,
2970 ) catch return jni.JNI_FALSE;
2971 events.settle(allocator, position.reject_global, json) catch return jni.JNI_FALSE;
3002 ) catch |err| return fellThrough("getCurrentPosition", err, jni.JNI_FALSE);
3003 events.settle(allocator, position.reject_global, json) catch |err| return fellThrough("getCurrentPosition", err, jni.JNI_FALSE);
29723004 return jni.JNI_TRUE;
29733005 }
29743006
packages/zig/src/bridge_android_device.zigmodified+16-4
Changes to packages/zig/src/bridge_android_device.zig
@@ -373,11 +373,23 @@ fn readPackageInfo(allocator: std.mem.Allocator, j: Jni, activity: jobject, sdk:
373373 errdefer allocator.free(version_name);
374374 allocator.free(fallback.version_name);
375375
376 // API 28 split the build number in two: `longVersionCode` is the whole
377 // value, `versionCode` its low 32 bits. Reading the wrong one on a modern
378 // app truncates rather than failing.
376 // API 28 split the build number in two: `getLongVersionCode()` is the
377 // whole value, `versionCode` its low 32 bits. Reading the wrong one on a
378 // modern app truncates rather than failing.
379 //
380 // A method, not a field. The Kotlin shim writes `.longVersionCode`, which
381 // Kotlin compiles to a call to the getter — there is no `longVersionCode`
382 // field on PackageInfo at all. This used to ask for one, and the first
383 // time it ran on a device, every call ended in
384 //
385 // java.lang.NoSuchFieldError: no "J" field "longVersionCode" in class
386 // "Landroid/content/pm/PackageInfo;" or its superclasses
387 //
388 // and fell through to the shim. It had never run anywhere before, because
389 // no generated app had ever loaded this library. `versionCode` below is a
390 // genuine public field, deprecated but present.
379391 const build: i64 = if (sdk >= 28)
380 try j.longField(info, try j.fieldId(info_cls, "longVersionCode", "J"))
392 try j.callLongMethod(info, try j.methodId(info_cls, "getLongVersionCode", "()J"))
381393 else
382394 try j.intField(info, try j.fieldId(info_cls, "versionCode", "I"));
383395
packages/zig/test/android_declines_test.zigadded+201-0
Changes to packages/zig/test/android_declines_test.zig
@@ -0,0 +1,201 @@
1//! Every way an Android native gives up has to say so.
2//!
3//! A native in `src/android_dispatch.zig` declines by returning `null` or
4//! `JNI_FALSE`, and the Kotlin shim then serves the action itself. That hand-
5//! back is by design and invisible to the page — which is also what made it
6//! invisible to everyone else. `getDeviceInfo` ran on its first emulator
7//! throwing
8//!
9//! java.lang.NoSuchFieldError: no "J" field "longVersionCode" in class
10//! "Landroid/content/pm/PackageInfo;"
11//!
12//! on every call, and nothing noticed: the page got an answer, the suite
13//! passed, and the only trace was one warning in logcat. About 170 other
14//! failure paths did not even leave that — `catch return null`, `catch {}` —
15//! so a JNI plumbing failure in any of them looked exactly like a native doing
16//! its job.
17//!
18//! They go through three helpers now, each with a distinct message:
19//! `fellThrough`, `failedWithoutFallback` and `undelivered`. The mobile E2E
20//! runtime leg fails on any of those lines, which is what lets it claim Zig
21//! *served* the calls it exercised rather than merely loaded.
22//!
23//! ## The rule
24//!
25//! No `catch` in the dispatcher may discard its error: a `catch` without a
26//! `|capture|` whose handler is only `return`, `return null`,
27//! `return jni.JNI_FALSE`, `null` or `{}` is a silent decline and fails this
28//! test. The compiler already refuses an unused capture, so once the error is
29//! captured it has to go somewhere.
30//!
31//! Two shapes stay allowed, deliberately:
32//!
33//! - `catch return .some_outcome` — returning an enum outcome that the caller
34//! reports. `bindNatives` does this and `JNI_OnLoad` logs every outcome.
35//! - `catch { ... }` with a body — a handler that does something, like the
36//! `saveFile` path that rejects its promise with a specific message.
37//!
38//! Scoped to the dispatcher because that is where declines to Kotlin happen.
39//! The bridge modules return errors with `try` and the dispatcher is what
40//! turns them into a decline.
41
42const std = ;
43const testing = std.testing;
44
45const dispatch = @embedFile("android_dispatch.zig");
46
47const Finding = struct {
48 line: usize,
49 text: []const u8,
50};
51
52/// The first silent decline in `source`, or null if there is none.
53fn firstSilentCatch(source: []const u8) ?Finding {
54 var lines = std.mem.splitScalar(u8, source, '\n');
55 var number: usize = 0;
56 while (lines.next()) |raw| {
57 number += 1;
58 const trimmed = std.mem.trim(u8, raw, " \t\r");
59 // Comments and multiline string lines describe code, they are not code.
60 if (std.mem.startsWith(u8, trimmed, "//") or std.mem.startsWith(u8, trimmed, "\\\\")) continue;
61
62 const code = withoutTrailingComment(raw);
63 var search: usize = 0;
64 while (std.mem.indexOfPos(u8, code, search, "catch")) |at| {
65 search = at + "catch".len;
66 if (!isWordAt(code, at, "catch".len)) continue;
67
68 const rest = std.mem.trimStart(u8, code[search..], " \t");
69 if (isSilentHandler(rest)) return .{ .line = number, .text = trimmed };
70 }
71 }
72 return null;
73}
74
75/// Whether what follows a `catch` discards the error without a capture.
76fn isSilentHandler(rest: []const u8) bool {
77 if (std.mem.startsWith(u8, rest, "|")) return false;
78
79 if (std.mem.startsWith(u8, rest, "{}")) return true;
80 if (std.mem.startsWith(u8, rest, "null")) return !continuesIdentifier(rest, "null".len);
81
82 if (std.mem.startsWith(u8, rest, "return")) {
83 if (continuesIdentifier(rest, "return".len)) return false;
84 const value = std.mem.trimStart(u8, rest["return".len..], " \t");
85 // An enum outcome the caller reports, like `bindNatives`.
86 if (std.mem.startsWith(u8, value, ".")) return false;
87 if (value.len == 0) return true;
88 if (value[0] == ';' or value[0] == ',' or value[0] == ')') return true;
89 if (std.mem.startsWith(u8, value, "null")) return !continuesIdentifier(value, "null".len);
90 if (std.mem.startsWith(u8, value, "jni.JNI_FALSE")) return !continuesIdentifier(value, "jni.JNI_FALSE".len);
91 }
92 return false;
93}
94
95fn continuesIdentifier(text: []const u8, len: usize) bool {
96 if (text.len <= len) return false;
97 const c = text[len];
98 return std.ascii.isAlphanumeric(c) or c == '_';
99}
100
101fn isWordAt(text: []const u8, at: usize, len: usize) bool {
102 const before_ok = at == 0 or !(std.ascii.isAlphanumeric(text[at - 1]) or text[at - 1] == '_');
103 return before_ok and !continuesIdentifier(text[at..], len);
104}
105
106/// `line` up to a `//` that is not inside a string literal.
107///
108/// URLs are why this is not `indexOf("//")`: the dispatcher builds
109/// `https://` strings, and cutting there would hide any `catch` after one.
110fn withoutTrailingComment(line: []const u8) []const u8 {
111 var in_string = false;
112 var i: usize = 0;
113 while (i < line.len) : (i += 1) {
114 const c = line[i];
115 if (in_string) {
116 if (c == '\\') {
117 i += 1;
118 } else if (c == '"') {
119 in_string = false;
120 }
121 } else if (c == '"') {
122 in_string = true;
123 } else if (c == '/' and i + 1 < line.len and line[i + 1] == '/') {
124 return line[0..i];
125 }
126 }
127 return line;
128}
129
130test "the scanner recognises every silent shape the dispatcher used to have" {
131 // Handed both answers before it is trusted with the real file: a scanner
132 // that finds nothing proves nothing until it has been shown to find
133 // something.
134 const silent = [_][]const u8{
135 " const text = j.stringToUtf8(allocator, value) catch return jni.JNI_FALSE;",
136 " const s = foo() catch return null;",
137 " return j.newStringUtf8(allocator, json) catch null;",
138 " events.settle(allocator, global, payload) catch {};",
139 " const text = j.stringToUtf8(allocator, message) catch return;",
140 " .latitude = json_number.fromDouble(j, allocator, latitude) catch return,",
141 " const call = (dbCall(j, allocator, sql) catch return jni.JNI_FALSE) orelse",
142 " const x = (render(allocator) catch return null) orelse",
143 " j.staticMethodId(holder, \"x\", \"()V\") catch return jni.JNI_FALSE,",
144 };
145 for (silent) |line| {
146 if (firstSilentCatch(line) == null) {
147 std.debug.print("\nthe scanner missed a silent decline:\n {s}\n", .{line});
148 return error.ScannerMissedSilentCatch;
149 }
150 }
151
152 const fine = [_][]const u8{
153 " const text = j.stringToUtf8(allocator, value) catch |err| return fellThrough(\"log\", err, jni.JNI_FALSE);",
154 " events.settle(allocator, global, payload) catch |err| undelivered(\"reviewError\", err);",
155 " const cls = j.findClass(holder_class) catch return .class_not_found;",
156 " const plan = files.planFor(data_text) catch {",
157 " // `catch return null` used to be everywhere",
158 " const url = \"https://example.com\"; // catch {} in a comment",
159 " const nullable = returnValue() catch |e| nullOr(e);",
160 };
161 for (fine) |line| {
162 if (firstSilentCatch(line)) |finding| {
163 std.debug.print("\nthe scanner flagged a handled catch:\n {s}\n", .{finding.text});
164 return error.ScannerFlaggedHandledCatch;
165 }
166 }
167}
168
169test "no native in the Android dispatcher declines without saying so" {
170 if (firstSilentCatch(dispatch)) |finding| {
171 std.debug.print(
172 \\
173 \\src/android_dispatch.zig:{d} declines without saying so:
174 \\
175 \\ {s}
176 \\
177 \\A native that gives up here hands the action back to the Kotlin shim
178 \\(or, from a callback, drops it) with nothing in logcat — the page still
179 \\gets an answer, so the failure is invisible. Capture the error and route
180 \\it through `fellThrough`, `failedWithoutFallback` or `undelivered`.
181 \\
182 , .{ finding.line, finding.text });
183 return error.SilentDecline;
184 }
185}
186
187test "the three decline messages are the ones the E2E suite looks for" {
188 // scripts/mobile-e2e/android.ts fails its runtime leg on these phrases. If
189 // a helper's wording drifted, the suite would stop seeing declines and pass
190 // on a runtime that was handing every call to Kotlin.
191 for ([_][]const u8{
192 " fell through to the shim (",
193 " failed with no fallback (",
194 " could not reach the page (",
195 }) |phrase| {
196 if (std.mem.indexOf(u8, dispatch, phrase) == null) {
197 std.debug.print("\nandroid_dispatch.zig no longer contains \"{s}\"\n", .{phrase});
198 return error.DeclinePhraseMissing;
199 }
200 }
201}
scripts/mobile-e2e/android.tsmodified+24-10
Changes to scripts/mobile-e2e/android.ts
@@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { init } from '../../packages/android/src/index'
5import { evaluateRun, hasTerminated } from './protocol'
5import { androidDeclines, evaluateRun, hasTerminated } from './protocol'
66import { command, driverPage, waitForFile } from './support'
77
88/**
@@ -189,17 +189,23 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
189189 failures.push(`the bridge reported sdkVersion ${observed[1]} but the device says ${sdk}`)
190190 }
191191
192 // Did the Zig runtime actually load and bind? JNI_OnLoad says so itself, on
193 // the happy path as well as the failure ones — which is deliberate, because
194 // "loaded and bound" and "never shipped" are otherwise both silent, and this
195 // is also the only line that proves the log channel is working rather than
196 // merely quiet.
192 // Did the Zig runtime load, bind — and then actually answer? JNI_OnLoad
193 // reports the first two on the happy path as well as the failure ones, and
194 // every way a native can give up now says so too (android_dispatch.zig's
195 // fellThrough / failedWithoutFallback / undelivered).
197196 //
198 // There is no per-action dispatch line to count the way the iOS legs do.
199 // Binding is the gate instead: once the natives are registered, every
200 // CraftNative wrapper returns a value and the Kotlin falls through to Zig
201 // rather than the other way round.
197 // Registration alone is not enough, and the first run that loaded this
198 // library showed why: "registered 103 natives", every case passed, and
199 // getDeviceInfo threw NoSuchFieldError on every call and handed it to Kotlin.
200 // The page could not tell. So the runtime leg asks for both halves: bound,
201 // and not one decline while the page ran.
202 //
203 // Read from the full buffer, not the filtered dump: the stack trace ART
204 // prints for the underlying Java exception is under System.err, which is
205 // the part worth quoting back.
202206 const registered = logText.match(REGISTERED)
207 const fullLog = existsSync(join(evidence, 'logcat-full.txt')) ? readFileSync(join(evidence, 'logcat-full.txt'), 'utf8') : logText
208 const declines = androidDeclines(fullLog)
203209 const zigActions: string[] = []
204210
205211 if (leg.requireZig) {
@@ -212,6 +218,14 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
212218 else {
213219 zigActions.push(`registered:${registered[1]}`)
214220 }
221
222 for (const decline of declines) {
223 failures.push(
224 `Zig declined while the page ran — ${decline}. The case still passed because Kotlin answered instead, `
225 + `which is exactly what this leg exists to see; the Java exception, if any, is under System.err in ${label}/logcat-full.txt`,
226 )
227 }
228 if (registered && !declines.length) zigActions.push('declines:0')
215229 }
216230 else if (registered) {
217231 failures.push(`the shim leg registered ${registered[1]} natives; it was generated with no runtime`)
scripts/mobile-e2e/protocol.test.tsmodified+44-1
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,5 +1,7 @@
11import { describe, expect, it } from 'bun:test'
2import { evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTION, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
2import { readFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTION, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
35
46const ESC = String.fromCharCode(27)
57
@@ -276,3 +278,44 @@ describe('zig attribution', () => {
276278 expect(ZIG_TESTED_ACTIONS).toContain(ZIG_REFUSED_ACTION)
277279 })
278280})
281
282describe('android declines', () => {
283 // The lines the first Android runtime run actually produced, plus the two
284 // decline kinds it did not hit, so all three are read the way they are
285 // written.
286 const logcat = [
287 '09-16 11:29:24.000 2611 2611 I CraftNative: craft: libcraft.so loaded',
288 '09-16 11:29:24.001 2611 2611 I CraftNative: craft: registered 103 natives on com/craft/runtime/CraftNative',
289 '09-16 11:29:24.535 2611 2705 W System.err: java.lang.NoSuchFieldError: no "J" field "longVersionCode"',
290 '09-16 11:29:24.535 2611 2705 W CraftNative: craft: getDeviceInfo fell through to the shim (JavaException)',
291 '09-16 11:29:25.000 2611 2705 E CraftNative: craft: locationResult failed with no fallback (OutOfMemory)',
292 '09-16 11:29:26.000 2611 2705 E CraftNative: craft: reviewError could not reach the page (NoWebView)',
293 '09-16 11:29:26.100 2611 2705 D CraftBridge: CRAFT-E2E {"event":"done","passed":4,"failed":0}',
294 ].join('\n')
295
296 it('reads every kind of decline, and names the action and the error', () => {
297 expect(androidDeclines(logcat)).toEqual([
298 'getDeviceInfo: fell through to the shim (JavaException)',
299 'locationResult: failed with no fallback (OutOfMemory)',
300 'reviewError: could not reach the page (NoWebView)',
301 ])
302 })
303
304 // The case that shipped: bound, every page case passing, and Kotlin
305 // answering. Registration is not a decline and must not be read as one —
306 // and a run with nothing but registration is the clean result.
307 it('does not mistake loading or registration for a decline', () => {
308 const clean = logcat.split('\n').filter(line => !/fell through|no fallback|reach the page/.test(line)).join('\n')
309 expect(androidDeclines(clean)).toEqual([])
310 })
311
312 it('matches the wording android_dispatch.zig actually writes', () => {
313 // Read from the Zig source rather than trusted, because each side alone
314 // would let a reworded helper turn the runtime leg blind: Zig would log a
315 // phrase this suite no longer matched, and every decline would pass.
316 const dispatch = readFileSync(join(import.meta.dir, '../../packages/zig/src/android_dispatch.zig'), 'utf8')
317 for (const phrase of ANDROID_DECLINE_PHRASES)
318 expect(dispatch).toContain(`" ${phrase} ({s})"`)
319 })
320})
321
scripts/mobile-e2e/protocol.tsmodified+37-0
Changes to scripts/mobile-e2e/protocol.ts
@@ -357,3 +357,40 @@ export const ZIG_TESTED_ACTIONS = [
357357
358358/** The one action the suite configures off, so Zig must refuse it by name. */
359359export const ZIG_REFUSED_ACTION = 'getCurrentPosition'
360
361/**
362 * The three ways an Android native says it gave up, as `android_dispatch.zig`
363 * writes them.
364 *
365 * - fell through to the shim: Kotlin served the action instead of Zig.
366 * - failed with no fallback: a callback into Zig gave up, and nothing will.
367 * - could not reach the page: Zig had the answer and could not deliver it.
368 *
369 * `test/android_declines_test.zig` keeps every decline routed through one of
370 * these, and `protocol.test.ts` reads the Zig source to check the wording has
371 * not drifted from what is matched here. Either half alone would let the suite
372 * stop seeing declines and keep passing.
373 */
374export const ANDROID_DECLINE_PHRASES = [
375 'fell through to the shim',
376 'failed with no fallback',
377 'could not reach the page',
378]
379
380/**
381 * Every Android decline line in a logcat dump, as `action: phrase (error)`.
382 *
383 * Why the runtime leg needs this rather than the registration line alone: the
384 * first run in which the Zig library loaded reported "registered 103 natives"
385 * and passed every case — while `getDeviceInfo` threw `NoSuchFieldError` on
386 * every call and Kotlin quietly answered instead. Registration proves the
387 * library bound. Only the absence of declines, with every decline made to
388 * speak, proves Zig answered.
389 */
390export function androidDeclines(text: string): string[] {
391 const plain = text.replace(ANSI, '')
392 const phrases = ANDROID_DECLINE_PHRASES.map(phrase => phrase.replace(/ /g, '\\s')).join('|')
393 const pattern = new RegExp(`craft: (\\w+) (${phrases}) \\(([^)]*)\\)`, 'g')
394 return [...plain.matchAll(pattern)].map(match => `${match[1]}: ${match[2]} (${match[3]})`)
395}
396