ReviewOS

also looking at this

craft-native/craft

feat(android): the notification cancels, and the flashlight pair deferred

#151
Merged chris wants to merge feat/android-notification-cancels into main
6 files +303 -4
packages/android/templates/CraftBridge.kt.templatemodified+4-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2414,12 +2414,16 @@ class CraftBridge(
24142414
24152415 @JavascriptInterface
24162416 fun cancelNotification(id: String) {
2417 if (CraftNative.cancelNotification(activity, id)) return
2418
24172419 val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
24182420 notificationManager.cancel(id.hashCode())
24192421 }
24202422
24212423 @JavascriptInterface
24222424 fun cancelAllNotifications() {
2425 if (CraftNative.cancelAllNotifications(activity)) return
2426
24232427 val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
24242428 notificationManager.cancelAll()
24252429 }
packages/android/templates/CraftNative.kt.templatemodified+22-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -72,6 +72,8 @@ object CraftNative {
7272 private external fun nativeSecureClear(prefs: SharedPreferences): Boolean
7373 private external fun nativeHaptic(activity: Activity, style: String): Boolean
7474 private external fun nativeVibrate(activity: Activity, patternJson: String): Boolean
75 private external fun nativeCancelNotification(activity: Activity, id: String): Boolean
76 private external fun nativeCancelAllNotifications(activity: Activity): Boolean
7577
7678 /**
7779 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -278,4 +280,24 @@ object CraftNative {
278280 false
279281 }
280282 }
283
284 /** Returns whether Zig cancelled it; false falls through. */
285 fun cancelNotification(activity: Activity, id: String): Boolean {
286 if (!isAvailable) return false
287 return try {
288 nativeCancelNotification(activity, id)
289 } catch (e: UnsatisfiedLinkError) {
290 false
291 }
292 }
293
294 /** Returns whether Zig cancelled them all; false falls through. */
295 fun cancelAllNotifications(activity: Activity): Boolean {
296 if (!isAvailable) return false
297 return try {
298 nativeCancelAllNotifications(activity)
299 } catch (e: UnsatisfiedLinkError) {
300 false
301 }
302 }
281303}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -452,6 +452,9 @@ pub fn build(b: *std.Build) void {
452452 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_haptics.zig", .{
453453 .root_source_file = b.path("src/bridge_android_haptics.zig"),
454454 });
455 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_notifcancel.zig", .{
456 .root_source_file = b.path("src/bridge_android_notifcancel.zig"),
457 });
455458 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
456459
457460 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+38-1
Changes to packages/zig/src/android_dispatch.zig
@@ -43,6 +43,7 @@ const intents = ;
4343const network = ;
4444const securestore = ;
4545const haptics = ;
46const notifcancel = ;
4647
4748const Jni = jni.Jni;
4849
@@ -201,6 +202,16 @@ const natives = [_]jni.JNINativeMethod{
201202 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
202203 .fnPtr = @ptrCast(&nativeVibrate),
203204 },
205 .{
206 .name = "nativeCancelNotification",
207 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
208 .fnPtr = @ptrCast(&nativeCancelNotification),
209 },
210 .{
211 .name = "nativeCancelAllNotifications",
212 .signature = "(Landroid/app/Activity;)Z",
213 .fnPtr = @ptrCast(&nativeCancelAllNotifications),
214 },
204215};
205216
206217/// How binding went. A value rather than a log line, so every path is
@@ -545,6 +556,32 @@ fn nativeVibrate(
545556 return jni.JNI_TRUE;
546557}
547558
559/// The jstring goes through untouched — the id is hashed by Java, and
560/// converting to UTF-8 first would throw away the representation the hash is
561/// defined over. See bridge_android_notifcancel.
562fn nativeCancelNotification(
563 env: jni.JNIEnv,
564 _: jni.jobject,
565 activity: jni.jobject,
566 id: jni.jstring,
567) callconv(.c) jni.jboolean {
568 const j = Jni.init(env);
569 notifcancel.cancel(j, activity, id) catch |err| {
570 std.log.warn("craft: cancelNotification fell through to the shim ({s})", .{@errorName(err)});
571 return jni.JNI_FALSE;
572 };
573 return jni.JNI_TRUE;
574}
575
576fn nativeCancelAllNotifications(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) callconv(.c) jni.jboolean {
577 const j = Jni.init(env);
578 notifcancel.cancelAll(j, activity) catch |err| {
579 std.log.warn("craft: cancelAllNotifications fell through to the shim ({s})", .{@errorName(err)});
580 return jni.JNI_FALSE;
581 };
582 return jni.JNI_TRUE;
583}
584
548585// =============================================================================
549586// Tests
550587// =============================================================================
@@ -656,7 +693,7 @@ test "the registered natives name methods the Kotlin actually declares" {
656693 // A descriptor is checked by the JVM at registration, so a wrong one fails
657694 // at load rather than at call — but only if the *name* matches something.
658695 // These two strings are the contract with CraftBridge.kt.
659 try testing.expectEqual(@as(usize, 14), natives.len);
696 try testing.expectEqual(@as(usize, 16), natives.len);
660697 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
661698
662699 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_notifcancel.zigadded+217-0
Changes to packages/zig/src/bridge_android_notifcancel.zig
@@ -0,0 +1,217 @@
1//! `cancelNotification` and `cancelAllNotifications` on Android.
2//!
3//! ## The id is hashed by Java, not by Zig
4//!
5//! `NotificationManager.cancel` takes an `int`, and the page sends a string —
6//! so the Kotlin cancels `id.hashCode()`. Getting that number wrong cancels a
7//! different notification or none at all, silently either way.
8//!
9//! Zig could reimplement it: `h = 31*h + c` over the characters, wrapping at
10//! 32 bits. It does not, and the reason is not effort. Java hashes **UTF-16
11//! code units**, so a notification id carrying an emoji is two surrogate units
12//! there and four bytes in the UTF-8 this bridge otherwise works in — and the
13//! two produce different numbers. Reproducing that means converting back to
14//! UTF-16 first, which is reimplementing the encoding to reimplement the hash.
15//!
16//! Calling `hashCode()` on the `jstring` the JVM already holds cannot diverge.
17//! It costs one JNI call on a path that makes four, and it is the same
18//! judgement `bridge_android_securestore.zig` makes about the store: where the
19//! platform already has the thing, ask it rather than rebuild it.
20//!
21//! The cost is that nothing here is host-testable, and that is the honest
22//! trade — a test of a hash this file does not compute would be testing a
23//! second implementation that does not ship.
24//!
25//! ## Cancelling an unknown id is a no-op that still succeeds
26//!
27//! `cancel` ignores an id it does not have, and so does the Kotlin. That
28//! matches the iOS notification cancels next door, which document the same
29//! idempotence for the same reason: a page clearing a notification it already
30//! dismissed has done nothing wrong.
31
32const std = ;
33const jni = ;
34
35const Jni = jni.Jni;
36const jobject = jni.jobject;
37
38pub const A = struct {
39 pub const cancel_notification = "cancelNotification";
40 pub const cancel_all_notifications = "cancelAllNotifications";
41};
42
43/// `activity.getSystemService(Context.NOTIFICATION_SERVICE)`.
44fn notificationManager(j: Jni, activity: jobject) !jobject {
45 const context_cls = try j.findClass("android/content/Context");
46 const name = try j.staticObjectField(
47 context_cls,
48 try j.staticFieldId(context_cls, "NOTIFICATION_SERVICE", "Ljava/lang/String;"),
49 );
50
51 const activity_cls = try j.objectClass(activity);
52 return j.callObjectMethodA(
53 activity,
54 try j.methodId(activity_cls, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"),
55 &.{.{ .l = name }},
56 );
57}
58
59/// `notificationManager.cancel(id.hashCode())`.
60///
61/// Takes the `jstring` rather than a Zig slice on purpose: the hash has to
62/// come from the Java object, and converting to UTF-8 first would throw away
63/// exactly the representation the hash is defined over.
64pub fn cancel(j: Jni, activity: jobject, id: jni.jstring) !void {
65 if (id == null) return jni.JniError.NullReference;
66
67 try j.pushLocalFrame(16);
68 defer _ = j.popLocalFrame(null);
69
70 const id_cls = try j.objectClass(id);
71 const hash = try j.callIntMethod(id, try j.methodId(id_cls, "hashCode", "()I"));
72
73 const manager = try notificationManager(j, activity);
74 const manager_cls = try j.objectClass(manager);
75 try j.callVoidMethodA(
76 manager,
77 try j.methodId(manager_cls, "cancel", "(I)V"),
78 &.{.{ .i = hash }},
79 );
80}
81
82/// `notificationManager.cancelAll()`.
83pub fn cancelAll(j: Jni, activity: jobject) !void {
84 try j.pushLocalFrame(16);
85 defer _ = j.popLocalFrame(null);
86
87 const manager = try notificationManager(j, activity);
88 const manager_cls = try j.objectClass(manager);
89 try j.callVoidMethodA(
90 manager,
91 try j.methodId(manager_cls, "cancelAll", "()V"),
92 &.{},
93 );
94}
95
96// =============================================================================
97// Tests
98//
99// A fake NotificationManager, because the one thing worth pinning here is the
100// *sequence*: the id must be hashed by Java and the result handed to `cancel`,
101// rather than any other integer reaching it.
102// =============================================================================
103
104const testing = std.testing;
105
106var fake_storage: [8]u8 = undefined;
107var fake_cancelled_with: jni.jint = 0;
108var fake_cancel_all_called = false;
109var fake_hash: jni.jint = 0;
110
111fn cobj(tag: usize) jobject {
112 return @ptrCast(&fake_storage[tag]);
113}
114fn cName(id: ?*anyopaque) []const u8 {
115 return std.mem.span(@as([*:0]const u8, @ptrCast(id.?)));
116}
117fn cFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass {
118 return cobj(0);
119}
120fn cObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass {
121 return cobj(0);
122}
123fn cStaticFieldId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID {
124 return cobj(1);
125}
126fn cStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject {
127 return cobj(2);
128}
129fn cMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID {
130 return @ptrCast(@constCast(name));
131}
132fn cCallIntMethod(_: jni.JNIEnv, _: jobject, id: jni.jmethodID) callconv(.c) jni.jint {
133 // Only `hashCode` reaches here, and answering something recognisable is
134 // what lets the assertion below be about the wiring rather than the value.
135 if (std.mem.eql(u8, cName(id), "hashCode")) return fake_hash;
136 return 0;
137}
138fn cCallObjectMethodA(_: jni.JNIEnv, _: jobject, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
139 return cobj(3);
140}
141fn cCallVoidMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) void {
142 const name = cName(id);
143 if (std.mem.eql(u8, name, "cancel")) fake_cancelled_with = args[0].i;
144 if (std.mem.eql(u8, name, "cancelAll")) fake_cancel_all_called = true;
145}
146fn cExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject {
147 return null;
148}
149fn cPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint {
150 return 0;
151}
152fn cPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject {
153 return keep;
154}
155
156fn fakeEnv(table: *jni.JNINativeInterface) void {
157 table.* = std.mem.zeroes(jni.JNINativeInterface);
158 table.FindClass = @ptrCast(&cFindClass);
159 table.GetObjectClass = @ptrCast(&cObjectClass);
160 table.GetStaticFieldID = @ptrCast(&cStaticFieldId);
161 table.GetStaticObjectField = @ptrCast(&cStaticObjectField);
162 table.GetMethodID = @ptrCast(&cMethodId);
163 table.CallIntMethod = @ptrCast(&cCallIntMethod);
164 table.CallObjectMethodA = @ptrCast(&cCallObjectMethodA);
165 table.CallVoidMethodA = @ptrCast(&cCallVoidMethodA);
166 table.ExceptionOccurred = @ptrCast(&cExceptionOccurred);
167 table.PushLocalFrame = @ptrCast(&cPush);
168 table.PopLocalFrame = @ptrCast(&cPop);
169 fake_cancelled_with = 0;
170 fake_cancel_all_called = false;
171}
172
173test "the id Java hashed is the id that gets cancelled" {
174 // The claim worth pinning: `cancel` receives `hashCode()`'s answer and not
175 // some other integer. A version that passed a length, an index, or a hash
176 // Zig computed itself would cancel a different notification — silently,
177 // because cancelling an id that does not exist succeeds.
178 var table: jni.JNINativeInterface = undefined;
179 fakeEnv(&table);
180 const ptr: *const jni.JNINativeInterface = &table;
181
182 fake_hash = -1_234_567;
183 try cancel(Jni.init(&ptr), cobj(4), cobj(5));
184 try testing.expectEqual(@as(jni.jint, -1_234_567), fake_cancelled_with);
185
186 // Negative hashes are ordinary — Java's is a signed 32-bit wrap — so the
187 // value must survive the trip unchanged rather than being made positive.
188 fake_hash = 0;
189 try cancel(Jni.init(&ptr), cobj(4), cobj(5));
190 try testing.expectEqual(@as(jni.jint, 0), fake_cancelled_with);
191}
192
193test "a null id is refused rather than cancelling notification zero" {
194 // `hashCode()` on null would throw, but the JNI call would have been made
195 // first — and an unchecked null here reaches `cancel(0)`, which quietly
196 // cancels whatever notification hashes to zero.
197 var table: jni.JNINativeInterface = undefined;
198 fakeEnv(&table);
199 const ptr: *const jni.JNINativeInterface = &table;
200
201 try testing.expectError(jni.JniError.NullReference, cancel(Jni.init(&ptr), cobj(4), null));
202 try testing.expectEqual(@as(jni.jint, 0), fake_cancelled_with);
203}
204
205test "cancelAll asks for cancelAll, not a cancel with some id" {
206 var table: jni.JNINativeInterface = undefined;
207 fakeEnv(&table);
208 const ptr: *const jni.JNINativeInterface = &table;
209
210 try cancelAll(Jni.init(&ptr), cobj(4));
211 try testing.expect(fake_cancel_all_called);
212}
213
214test "the action names match the Kotlin methods exactly" {
215 try testing.expectEqualStrings("cancelNotification", A.cancel_notification);
216 try testing.expectEqualStrings("cancelAllNotifications", A.cancel_all_notifications);
217}
packages/zig/test/android_conformance_test.zigmodified+19-3
Changes to packages/zig/test/android_conformance_test.zig
@@ -46,6 +46,7 @@ const zig_sources = [_][]const u8{
4646 @embedFile("src/bridge_android_network.zig"),
4747 @embedFile("src/bridge_android_securestore.zig"),
4848 @embedFile("src/bridge_android_haptics.zig"),
49 @embedFile("src/bridge_android_notifcancel.zig"),
4950};
5051
5152/// How many spec actions Zig does not serve yet.
@@ -59,8 +60,9 @@ const zig_sources = [_][]const u8{
5960/// clipboard pair, the first that do; 96 with openURL and share; 95 with
6061/// getNetworkStatus, which iOS declares unavailable and Android can answer;
6162/// 91 with the secure-storage quartet, the first to take a Kotlin-held object
62/// rather than the Activity; 89 with haptic and vibrate.
63const max_not_yet_migrated: usize = 89;
63/// rather than the Activity; 89 with haptic and vibrate; 87 with the
64/// notification cancels.
65const max_not_yet_migrated: usize = 87;
6466
6567/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
6668///
@@ -156,6 +158,20 @@ const deliberate_deferrals = [_]Deferral{
156158 // page cannot tell, because the same call works on iOS. Tracked in #148.
157159 .{ .action = "setBadge", .reason = "the Kotlin discards the manager it builds and never reads count; there is no working contract to port" },
158160 .{ .action = "clearBadge", .reason = "delegates to setBadge, which does nothing" },
161
162 // Kotlin-held state, not a missing API. `setFlashlight` writes
163 // `isFlashlightOn` (`CraftBridge.kt:2011`) and `toggleFlashlight` reads it
164 // to decide what to flip to (`:2026`). Serving `setFlashlight` from Zig
165 // means the Kotlin's body never runs, so the field stops tracking the
166 // torch — and the next `toggleFlashlight` inverts a stale value and turns
167 // the light on when it is already on.
168 //
169 // Zig cannot update the field either: the natives are registered on
170 // `CraftNative`, which has no reference to the `CraftBridge` instance that
171 // owns it. Migrating the pair together would need the state to move too,
172 // and that is a change to the shim rather than a migration away from it.
173 .{ .action = "setFlashlight", .reason = "writes isFlashlightOn, which toggleFlashlight reads; Zig cannot reach the field" },
174 .{ .action = "toggleFlashlight", .reason = "reads isFlashlightOn, which only the Kotlin setFlashlight maintains" },
159175};
160176
161177test "every recorded deferral is real, and still a deferral" {
@@ -189,7 +205,7 @@ test "every recorded deferral is real, and still a deferral" {
189205 }
190206
191207 // Non-vacuity: the loop above is satisfied by an empty table.
192 try testing.expect(deliberate_deferrals.len >= 2);
208 try testing.expect(deliberate_deferrals.len >= 4);
193209
194210 // And the table cannot claim more than remain unmigrated.
195211 try testing.expect(deliberate_deferrals.len <= max_not_yet_migrated);