ReviewOS

also looking at this

craft-native/craft

feat(android): the immediate half of scheduleNotification — ratchet 73 → 72

#172
Merged chris wants to merge feat/android-schedule-notification into main
7 files +690 -227
packages/android/templates/CraftBridge.kt.templatemodified+5-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2406,6 +2406,11 @@ class CraftBridge(
24062406
24072407 @JavascriptInterface
24082408 fun scheduleNotification(notificationJson: String) {
2409 // Zig answers through the reply channel, so returning here is what
2410 // stops the promise being settled twice. It declines a delayed
2411 // notification before doing any of the work below.
2412 if (CraftNative.scheduleNotification(activity, notificationJson)) return
2413
24092414 try {
24102415 val notif = JSONObject(notificationJson)
24112416 val title = notif.optString("title", "")
packages/android/templates/CraftNative.kt.templatemodified+18-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -122,6 +122,7 @@ object CraftNative {
122122 private external fun nativeReloadWidgets(activity: Activity, action: String): Boolean
123123 private external fun nativeSetShortcuts(activity: Activity, shortcutsJson: String): Boolean
124124 private external fun nativeClearShortcuts(activity: Activity): Boolean
125 private external fun nativeScheduleNotification(activity: Activity, notificationJson: String): Boolean
125126
126127 /**
127128 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -537,4 +538,21 @@ object CraftNative {
537538 false
538539 }
539540 }
541
542 /**
543 * Returns whether Zig posted the notification.
544 *
545 * A false is routine on one path in particular: a `delay` above zero needs
546 * `Handler.postDelayed`, and Zig cannot make a `Runnable`. It hands those
547 * back before creating the channel, so the Kotlin below does the whole
548 * thing rather than half of it twice.
549 */
550 fun scheduleNotification(activity: Activity, notificationJson: String): Boolean {
551 if (!isAvailable) return false
552 return try {
553 nativeScheduleNotification(activity, notificationJson)
554 } catch (e: UnsatisfiedLinkError) {
555 false
556 }
557 }
540558}
packages/zig/build.zigmodifiedrenamed throughout+2-2
Changes to packages/zig/build.zig
@@ -452,8 +452,8 @@ 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"),
455 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_notifications.zig", .{
456 .root_source_file = b.path("src/bridge_android_notifications.zig"),
457457 });
458458 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_calendar.zig", .{
459459 .root_source_file = b.path("src/bridge_android_calendar.zig"),
packages/zig/src/android_dispatch.zigmodified+60-5
Changes to packages/zig/src/android_dispatch.zig
@@ -44,7 +44,7 @@ const intents = ;
4444const network = ;
4545const securestore = ;
4646const haptics = ;
47const notifcancel = ;
47const notifications = ;
4848const events = ;
4949const calendar = ;
5050const db = ;
@@ -318,6 +318,11 @@ const natives = [_]jni.JNINativeMethod{
318318 .signature = "(Landroid/app/Activity;)Z",
319319 .fnPtr = @ptrCast(&nativeClearShortcuts),
320320 },
321 .{
322 .name = "nativeScheduleNotification",
323 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
324 .fnPtr = @ptrCast(&nativeScheduleNotification),
325 },
321326};
322327
323328/// How binding went. A value rather than a log line, so every path is
@@ -649,7 +654,7 @@ fn nativeVibrate(
649654
650655/// The jstring goes through untouched — the id is hashed by Java, and
651656/// converting to UTF-8 first would throw away the representation the hash is
652/// defined over. See bridge_android_notifcancel.
657/// defined over. See bridge_android_notifications.
653658fn nativeCancelNotification(
654659 env: jni.JNIEnv,
655660 _: jni.jobject,
@@ -657,7 +662,7 @@ fn nativeCancelNotification(
657662 id: jni.jstring,
658663) callconv(.c) jni.jboolean {
659664 const j = Jni.init(env);
660 notifcancel.cancel(j, activity, id) catch |err| {
665 notifications.cancel(j, activity, id) catch |err| {
661666 std.log.warn("craft: cancelNotification fell through to the shim ({s})", .{@errorName(err)});
662667 return jni.JNI_FALSE;
663668 };
@@ -666,7 +671,7 @@ fn nativeCancelNotification(
666671
667672fn nativeCancelAllNotifications(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) callconv(.c) jni.jboolean {
668673 const j = Jni.init(env);
669 notifcancel.cancelAll(j, activity) catch |err| {
674 notifications.cancelAll(j, activity) catch |err| {
670675 std.log.warn("craft: cancelAllNotifications fell through to the shim ({s})", .{@errorName(err)});
671676 return jni.JNI_FALSE;
672677 };
@@ -1246,6 +1251,56 @@ fn nativeClearShortcuts(
12461251 return jni.JNI_TRUE;
12471252}
12481253
1254/// `nativeScheduleNotification(activity, notificationJson)`.
1255///
1256/// Serves only the immediate half. A `delay` above zero needs
1257/// `Handler.postDelayed`, which needs a `Runnable`, which Zig cannot make —
1258/// so it is handed back *before* the channel is created or the notification
1259/// built, because the shim does both on that path too and doing them twice
1260/// would post nothing visible but would still be wrong.
1261fn nativeScheduleNotification(
1262 env: jni.JNIEnv,
1263 _: jni.jobject,
1264 activity: jni.jobject,
1265 notification_json: jni.jstring,
1266) callconv(.c) jni.jboolean {
1267 const j = Jni.init(env);
1268 var arena = std.heap.ArenaAllocator.init(backing);
1269 defer arena.deinit();
1270 const allocator = arena.allocator();
1271
1272 const text = j.stringToUtf8(allocator, notification_json) catch return jni.JNI_FALSE;
1273
1274 // `JSONObject(notificationJson)` throwing is the shim's first catch, so a
1275 // payload this cannot parse goes back rather than being rejected here.
1276 var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch
1277 return jni.JNI_FALSE;
1278 defer parsed.deinit();
1279
1280 const default_id = notifications.defaultId(j, allocator) catch return jni.JNI_FALSE;
1281 const notification = notifications.parseNotification(parsed.value, default_id) orelse
1282 return jni.JNI_FALSE;
1283
1284 if (!notifications.servesImmediately(notification)) return jni.JNI_FALSE;
1285
1286 notifications.ensureChannel(j, activity) catch |err| {
1287 calendar.rejectOn(allocator, notifications.schedule_reject_global, @errorName(err)) catch {};
1288 return jni.JNI_TRUE;
1289 };
1290
1291 _ = notifications.post(j, allocator, activity, notification) catch |err| {
1292 calendar.rejectOn(allocator, notifications.schedule_reject_global, @errorName(err)) catch {};
1293 return jni.JNI_TRUE;
1294 };
1295
1296 // The id, not the hash: the page sent a string and gets the same string
1297 // back, which is what it needs to cancel with later.
1298 const payload = calendar.jsonString(allocator, notification.id) catch return jni.JNI_FALSE;
1299 events.settle(allocator, notifications.schedule_resolve_global, payload) catch
1300 return jni.JNI_FALSE;
1301 return jni.JNI_TRUE;
1302}
1303
12491304// =============================================================================
12501305// Tests
12511306// =============================================================================
@@ -1357,7 +1412,7 @@ test "the registered natives name methods the Kotlin actually declares" {
13571412 // A descriptor is checked by the JVM at registration, so a wrong one fails
13581413 // at load rather than at call — but only if the *name* matches something.
13591414 // These two strings are the contract with CraftBridge.kt.
1360 try testing.expectEqual(@as(usize, 30), natives.len);
1415 try testing.expectEqual(@as(usize, 31), natives.len);
13611416 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
13621417
13631418 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_notifcancel.zigdeleted+0-217
Changes to packages/zig/src/bridge_android_notifcancel.zig
@@ -1,217 +0,0 @@
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/src/bridge_android_notifications.zigadded+601-0
Changes to packages/zig/src/bridge_android_notifications.zig
@@ -0,0 +1,601 @@
1//! Local notifications on Android: `scheduleNotification`,
2//! `cancelNotification` and `cancelAllNotifications`.
3//!
4//! ## Only the immediate half of scheduling is served
5//!
6//! A `delay` above zero posts the notification through
7//! `Handler(Looper.getMainLooper()).postDelayed { ... }`, and Zig cannot make
8//! a `Runnable` — the same wall the reply channel exists to get around, except
9//! that this one needs a Java object rather than a Java call. So a delayed
10//! notification is handed back to the shim before anything has happened, and
11//! only `delay <= 0` is served here.
12//!
13//! Declining early matters: the shim creates the channel and builds the
14//! notification on both paths, so returning after any of that would do the
15//! work twice.
16//!
17//! ## The id is hashed by Java, not by Zig
18//!
19//! `NotificationManager.cancel` takes an `int`, and the page sends a string —
20//! so the Kotlin cancels `id.hashCode()`. Getting that number wrong cancels a
21//! different notification or none at all, silently either way.
22//!
23//! Zig could reimplement it: `h = 31*h + c` over the characters, wrapping at
24//! 32 bits. It does not, and the reason is not effort. Java hashes **UTF-16
25//! code units**, so a notification id carrying an emoji is two surrogate units
26//! there and four bytes in the UTF-8 this bridge otherwise works in — and the
27//! two produce different numbers. Reproducing that means converting back to
28//! UTF-16 first, which is reimplementing the encoding to reimplement the hash.
29//!
30//! Calling `hashCode()` on the `jstring` the JVM already holds cannot diverge.
31//! It costs one JNI call on a path that makes four, and it is the same
32//! judgement `bridge_android_securestore.zig` makes about the store: where the
33//! platform already has the thing, ask it rather than rebuild it.
34//!
35//! The cost is that nothing here is host-testable, and that is the honest
36//! trade — a test of a hash this file does not compute would be testing a
37//! second implementation that does not ship.
38//!
39//! ## Cancelling an unknown id is a no-op that still succeeds
40//!
41//! `cancel` ignores an id it does not have, and so does the Kotlin. That
42//! matches the iOS notification cancels next door, which document the same
43//! idempotence for the same reason: a page clearing a notification it already
44//! dismissed has done nothing wrong.
45
46const std = ;
47const jni = ;
48
49const Jni = jni.Jni;
50const jobject = jni.jobject;
51
52pub const A = struct {
53 pub const schedule_notification = "scheduleNotification";
54 pub const cancel_notification = "cancelNotification";
55 pub const cancel_all_notifications = "cancelAllNotifications";
56};
57
58/// `activity.getSystemService(Context.NOTIFICATION_SERVICE)`.
59fn notificationManager(j: Jni, activity: jobject) !jobject {
60 const context_cls = try j.findClass("android/content/Context");
61 const name = try j.staticObjectField(
62 context_cls,
63 try j.staticFieldId(context_cls, "NOTIFICATION_SERVICE", "Ljava/lang/String;"),
64 );
65
66 const activity_cls = try j.objectClass(activity);
67 return j.callObjectMethodA(
68 activity,
69 try j.methodId(activity_cls, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"),
70 &.{.{ .l = name }},
71 );
72}
73
74/// `notificationManager.cancel(id.hashCode())`.
75///
76/// Takes the `jstring` rather than a Zig slice on purpose: the hash has to
77/// come from the Java object, and converting to UTF-8 first would throw away
78/// exactly the representation the hash is defined over.
79pub fn cancel(j: Jni, activity: jobject, id: jni.jstring) !void {
80 if (id == null) return jni.JniError.NullReference;
81
82 try j.pushLocalFrame(16);
83 defer _ = j.popLocalFrame(null);
84
85 const id_cls = try j.objectClass(id);
86 const hash = try j.callIntMethod(id, try j.methodId(id_cls, "hashCode", "()I"));
87
88 const manager = try notificationManager(j, activity);
89 const manager_cls = try j.objectClass(manager);
90 try j.callVoidMethodA(
91 manager,
92 try j.methodId(manager_cls, "cancel", "(I)V"),
93 &.{.{ .i = hash }},
94 );
95}
96
97/// `notificationManager.cancelAll()`.
98pub fn cancelAll(j: Jni, activity: jobject) !void {
99 try j.pushLocalFrame(16);
100 defer _ = j.popLocalFrame(null);
101
102 const manager = try notificationManager(j, activity);
103 const manager_cls = try j.objectClass(manager);
104 try j.callVoidMethodA(
105 manager,
106 try j.methodId(manager_cls, "cancelAll", "()V"),
107 &.{},
108 );
109}
110
111// =============================================================================
112// scheduleNotification
113// =============================================================================
114
115/// The globals the injected JS assigns for scheduling.
116pub const schedule_resolve_global = "_craftNotifResolve";
117pub const schedule_reject_global = "_craftNotifReject";
118
119/// `CraftBridge.notificationChannelId`.
120const channel_id = "craft_notifications";
121const channel_name = "Craft Notifications";
122const channel_description = "Notifications from Craft app";
123
124/// `NotificationManager.IMPORTANCE_DEFAULT` and `Build.VERSION_CODES.O`.
125///
126/// Compile-time constants in Java, so the shim's DEX holds the literals.
127const importance_default: i32 = 3;
128const version_o: i32 = 26;
129
130/// What the page sent, in the shape `NotificationData` declares.
131pub const Notification = struct {
132 title: []const u8,
133 body: []const u8,
134 /// `optString("id", System.currentTimeMillis().toString())`.
135 id: []const u8,
136 /// `optLong("delay", 0)`. Above zero is the path Zig hands back.
137 delay: i64,
138};
139
140/// Read the declared shape, or null where only `org.json` would.
141///
142/// `default_id` is the shim's `System.currentTimeMillis().toString()`,
143/// computed by the caller because it is a JNI call rather than a host clock.
144pub fn parseNotification(value: std.json.Value, default_id: []const u8) ?Notification {
145 const object = switch (value) {
146 .object => |o| o,
147 else => return null,
148 };
149
150 return .{
151 .title = optString(object, "title", "") orelse return null,
152 .body = optString(object, "body", "") orelse return null,
153 .id = optString(object, "id", default_id) orelse return null,
154 .delay = optLong(object, "delay", 0) orelse return null,
155 };
156}
157
158fn optString(object: std.json.ObjectMap, name: []const u8, fallback: []const u8) ?[]const u8 {
159 const value = object.get(name) orelse return fallback;
160 return switch (value) {
161 .string => |text| text,
162 // `JSONObject.NULL.toString()`, the same four characters every other
163 // migrated action reproduces.
164 .null => "null",
165 else => null,
166 };
167}
168
169fn optLong(object: std.json.ObjectMap, name: []const u8, fallback: i64) ?i64 {
170 const value = object.get(name) orelse return fallback;
171 return switch (value) {
172 .integer => |n| n,
173 .null => fallback,
174 else => null,
175 };
176}
177
178/// Whether this notification is one Zig serves.
179///
180/// The shim's branch is `if (delay > 0)`, so zero and every negative go to
181/// `notify` immediately — a page sending `delay: -1` gets a notification now,
182/// not an error.
183pub fn servesImmediately(notification: Notification) bool {
184 return notification.delay <= 0;
185}
186
187/// `Build.VERSION.SDK_INT`.
188pub fn sdkInt(j: Jni) !i32 {
189 try j.pushLocalFrame(4);
190 defer _ = j.popLocalFrame(null);
191
192 const version_cls = try j.findClass("android/os/Build$VERSION");
193 return j.staticIntField(version_cls, try j.staticFieldId(version_cls, "SDK_INT", "I"));
194}
195
196/// `createNotificationChannel()`, which is a no-op below API 26.
197///
198/// Called before every `notify`, as the shim calls it — creating a channel
199/// that already exists updates nothing the app did not set, so it is cheap
200/// rather than merely harmless.
201pub fn ensureChannel(j: Jni, activity: jobject) !void {
202 if (try sdkInt(j) < version_o) return;
203
204 try j.pushLocalFrame(16);
205 defer _ = j.popLocalFrame(null);
206
207 const channel_cls = try j.findClass("android/app/NotificationChannel");
208 const channel = try j.newObjectA(
209 channel_cls,
210 try j.methodId(channel_cls, "<init>", "(Ljava/lang/String;Ljava/lang/CharSequence;I)V"),
211 &.{
212 .{ .l = try j.newStringUtf(channel_id) },
213 .{ .l = try j.newStringUtf(channel_name) },
214 .{ .i = importance_default },
215 },
216 );
217
218 try j.callVoidMethodA(
219 channel,
220 try j.methodId(channel_cls, "setDescription", "(Ljava/lang/String;)V"),
221 &.{.{ .l = try j.newStringUtf(channel_description) }},
222 );
223
224 const manager = try notificationManager(j, activity);
225 try j.callVoidMethodA(
226 manager,
227 try j.methodId(
228 try j.objectClass(manager),
229 "createNotificationChannel",
230 "(Landroid/app/NotificationChannel;)V",
231 ),
232 &.{.{ .l = channel }},
233 );
234}
235
236/// Build the notification and post it, returning the id's Java hash.
237///
238/// `NotificationCompat.Builder` rather than the framework's `Notification.
239/// Builder`, because that is what the shim uses and the two do not produce
240/// identical objects — compat writes its own extras, and a notification built
241/// one way and cancelled the other is a difference nobody would look for.
242/// `FindClass` reaches androidx because a native method's class loader is the
243/// one that declared it, and `CraftNative` is an app class.
244pub fn post(
245 j: Jni,
246 allocator: std.mem.Allocator,
247 activity: jobject,
248 notification: Notification,
249) !jni.jint {
250 try j.pushLocalFrame(32);
251 defer _ = j.popLocalFrame(null);
252
253 const builder_cls = try j.findClass("androidx/core/app/NotificationCompat$Builder");
254 const builder = try j.newObjectA(
255 builder_cls,
256 try j.methodId(builder_cls, "<init>", "(Landroid/content/Context;Ljava/lang/String;)V"),
257 &.{ .{ .l = activity }, .{ .l = try j.newStringUtf(channel_id) } },
258 );
259
260 // `android.R.drawable.ic_dialog_info`. A resource id is a number the
261 // platform assigns, so it is read rather than written down.
262 const r_drawable_cls = try j.findClass("android/R$drawable");
263 const icon = try j.staticIntField(
264 r_drawable_cls,
265 try j.staticFieldId(r_drawable_cls, "ic_dialog_info", "I"),
266 );
267 _ = try j.callObjectMethodA(
268 builder,
269 try j.methodId(builder_cls, "setSmallIcon", "(I)Landroidx/core/app/NotificationCompat$Builder;"),
270 &.{.{ .i = icon }},
271 );
272
273 _ = try j.callObjectMethodA(
274 builder,
275 try j.methodId(
276 builder_cls,
277 "setContentTitle",
278 "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
279 ),
280 &.{.{ .l = try j.newStringUtf8(allocator, notification.title) }},
281 );
282
283 _ = try j.callObjectMethodA(
284 builder,
285 try j.methodId(
286 builder_cls,
287 "setContentText",
288 "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
289 ),
290 &.{.{ .l = try j.newStringUtf8(allocator, notification.body) }},
291 );
292
293 // Read rather than written down: this is androidx's own constant, and it
294 // is the one number here that a version bump could move.
295 const priority_default = try j.staticIntField(
296 builder_cls,
297 try j.staticFieldId(builder_cls, "PRIORITY_DEFAULT", "I"),
298 );
299 _ = try j.callObjectMethodA(
300 builder,
301 try j.methodId(builder_cls, "setPriority", "(I)Landroidx/core/app/NotificationCompat$Builder;"),
302 &.{.{ .i = priority_default }},
303 );
304
305 _ = try j.callObjectMethodA(
306 builder,
307 try j.methodId(builder_cls, "setAutoCancel", "(Z)Landroidx/core/app/NotificationCompat$Builder;"),
308 &.{.{ .z = jni.JNI_TRUE }},
309 );
310
311 const built = try j.callObjectMethod(
312 builder,
313 try j.methodId(builder_cls, "build", "()Landroid/app/Notification;"),
314 );
315
316 // `id.hashCode()` — from the Java object, for the reason at the top of
317 // this file: Java hashes UTF-16 code units, and reproducing that means
318 // reimplementing the encoding to reimplement the hash.
319 const id_string = try j.newStringUtf8(allocator, notification.id);
320 const hash = try j.callIntMethod(
321 id_string,
322 try j.methodId(try j.objectClass(id_string), "hashCode", "()I"),
323 );
324
325 const manager = try notificationManager(j, activity);
326 try j.callVoidMethodA(
327 manager,
328 try j.methodId(
329 try j.objectClass(manager),
330 "notify",
331 "(ILandroid/app/Notification;)V",
332 ),
333 &.{ .{ .i = hash }, .{ .l = built } },
334 );
335 return hash;
336}
337
338/// `System.currentTimeMillis().toString()` — the shim's default id.
339pub fn defaultId(j: Jni, allocator: std.mem.Allocator) ![]u8 {
340 try j.pushLocalFrame(4);
341 defer _ = j.popLocalFrame(null);
342
343 const system_cls = try j.findClass("java/lang/System");
344 const now = try j.callStaticLongMethodA(
345 system_cls,
346 try j.staticMethodId(system_cls, "currentTimeMillis", "()J"),
347 &.{},
348 );
349 return std.fmt.allocPrint(allocator, "{d}", .{now});
350}
351
352// =============================================================================
353// Tests
354//
355// A fake NotificationManager, because the one thing worth pinning here is the
356// *sequence*: the id must be hashed by Java and the result handed to `cancel`,
357// rather than any other integer reaching it.
358// =============================================================================
359
360const testing = std.testing;
361
362var fake_storage: [8]u8 = undefined;
363var fake_cancelled_with: jni.jint = 0;
364var fake_cancel_all_called = false;
365var fake_hash: jni.jint = 0;
366
367fn cobj(tag: usize) jobject {
368 return @ptrCast(&fake_storage[tag]);
369}
370fn cName(id: ?*anyopaque) []const u8 {
371 return std.mem.span(@as([*:0]const u8, @ptrCast(id.?)));
372}
373fn cFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass {
374 return cobj(0);
375}
376fn cObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass {
377 return cobj(0);
378}
379fn cStaticFieldId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID {
380 return cobj(1);
381}
382fn cStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject {
383 return cobj(2);
384}
385fn cMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID {
386 return @ptrCast(@constCast(name));
387}
388fn cCallIntMethod(_: jni.JNIEnv, _: jobject, id: jni.jmethodID) callconv(.c) jni.jint {
389 // Only `hashCode` reaches here, and answering something recognisable is
390 // what lets the assertion below be about the wiring rather than the value.
391 if (std.mem.eql(u8, cName(id), "hashCode")) return fake_hash;
392 return 0;
393}
394fn cCallObjectMethodA(_: jni.JNIEnv, _: jobject, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
395 return cobj(3);
396}
397fn cCallVoidMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) void {
398 const name = cName(id);
399 if (std.mem.eql(u8, name, "cancel")) fake_cancelled_with = args[0].i;
400 if (std.mem.eql(u8, name, "cancelAll")) fake_cancel_all_called = true;
401}
402fn cExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject {
403 return null;
404}
405fn cPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint {
406 return 0;
407}
408fn cPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject {
409 return keep;
410}
411
412fn fakeEnv(table: *jni.JNINativeInterface) void {
413 table.* = std.mem.zeroes(jni.JNINativeInterface);
414 table.FindClass = @ptrCast(&cFindClass);
415 table.GetObjectClass = @ptrCast(&cObjectClass);
416 table.GetStaticFieldID = @ptrCast(&cStaticFieldId);
417 table.GetStaticObjectField = @ptrCast(&cStaticObjectField);
418 table.GetMethodID = @ptrCast(&cMethodId);
419 table.CallIntMethod = @ptrCast(&cCallIntMethod);
420 table.CallObjectMethodA = @ptrCast(&cCallObjectMethodA);
421 table.CallVoidMethodA = @ptrCast(&cCallVoidMethodA);
422 table.ExceptionOccurred = @ptrCast(&cExceptionOccurred);
423 table.PushLocalFrame = @ptrCast(&cPush);
424 table.PopLocalFrame = @ptrCast(&cPop);
425 fake_cancelled_with = 0;
426 fake_cancel_all_called = false;
427}
428
429test "the id Java hashed is the id that gets cancelled" {
430 // The claim worth pinning: `cancel` receives `hashCode()`'s answer and not
431 // some other integer. A version that passed a length, an index, or a hash
432 // Zig computed itself would cancel a different notification — silently,
433 // because cancelling an id that does not exist succeeds.
434 var table: jni.JNINativeInterface = undefined;
435 fakeEnv(&table);
436 const ptr: *const jni.JNINativeInterface = &table;
437
438 fake_hash = -1_234_567;
439 try cancel(Jni.init(&ptr), cobj(4), cobj(5));
440 try testing.expectEqual(@as(jni.jint, -1_234_567), fake_cancelled_with);
441
442 // Negative hashes are ordinary — Java's is a signed 32-bit wrap — so the
443 // value must survive the trip unchanged rather than being made positive.
444 fake_hash = 0;
445 try cancel(Jni.init(&ptr), cobj(4), cobj(5));
446 try testing.expectEqual(@as(jni.jint, 0), fake_cancelled_with);
447}
448
449test "a null id is refused rather than cancelling notification zero" {
450 // `hashCode()` on null would throw, but the JNI call would have been made
451 // first — and an unchecked null here reaches `cancel(0)`, which quietly
452 // cancels whatever notification hashes to zero.
453 var table: jni.JNINativeInterface = undefined;
454 fakeEnv(&table);
455 const ptr: *const jni.JNINativeInterface = &table;
456
457 try testing.expectError(jni.JniError.NullReference, cancel(Jni.init(&ptr), cobj(4), null));
458 try testing.expectEqual(@as(jni.jint, 0), fake_cancelled_with);
459}
460
461test "cancelAll asks for cancelAll, not a cancel with some id" {
462 var table: jni.JNINativeInterface = undefined;
463 fakeEnv(&table);
464 const ptr: *const jni.JNINativeInterface = &table;
465
466 try cancelAll(Jni.init(&ptr), cobj(4));
467 try testing.expect(fake_cancel_all_called);
468}
469
470test "the action names match the Kotlin methods exactly" {
471 try testing.expectEqualStrings("cancelNotification", A.cancel_notification);
472 try testing.expectEqualStrings("cancelAllNotifications", A.cancel_all_notifications);
473}
474
475// --- scheduleNotification --------------------------------------------------
476
477fn parsedNotification(json: []const u8, default_id: []const u8) !?Notification {
478 var arena = std.heap.ArenaAllocator.init(testing.allocator);
479 defer arena.deinit();
480
481 var doc = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json, .{});
482 defer doc.deinit();
483
484 const parsed = parseNotification(doc.value, default_id) orelse return null;
485 return Notification{
486 .title = try testing.allocator.dupe(u8, parsed.title),
487 .body = try testing.allocator.dupe(u8, parsed.body),
488 .id = try testing.allocator.dupe(u8, parsed.id),
489 .delay = parsed.delay,
490 };
491}
492
493fn freeNotification(n: Notification) void {
494 testing.allocator.free(n.title);
495 testing.allocator.free(n.body);
496 testing.allocator.free(n.id);
497}
498
499test "the declared shape reads straight through" {
500 const n = (try parsedNotification(
501 \\{"title":"Order shipped","body":"Arriving Tuesday","id":"order-7","delay":0}
502 , "1700000000000")).?;
503 defer freeNotification(n);
504
505 try testing.expectEqualStrings("Order shipped", n.title);
506 try testing.expectEqualStrings("Arriving Tuesday", n.body);
507 try testing.expectEqualStrings("order-7", n.id);
508 try testing.expectEqual(@as(i64, 0), n.delay);
509}
510
511test "an absent id becomes the clock, and absent text becomes empty" {
512 // `optString("id", System.currentTimeMillis().toString())`. The default is
513 // computed by the caller because it is a JNI call rather than a host
514 // clock, and it is passed in so this stays a pure function.
515 const n = (try parsedNotification("{}", "1700000000000")).?;
516 defer freeNotification(n);
517
518 try testing.expectEqualStrings("", n.title);
519 try testing.expectEqualStrings("", n.body);
520 try testing.expectEqualStrings("1700000000000", n.id);
521 try testing.expectEqual(@as(i64, 0), n.delay);
522}
523
524test "a delay above zero is the shim's, and everything else is served here" {
525 // `if (delay > 0)` — so a negative delay is not an error and not a
526 // rejection: the shim posts it immediately, and so does this.
527 for ([_]struct { delay: i64, served: bool }{
528 .{ .delay = 0, .served = true },
529 .{ .delay = -1, .served = true },
530 .{ .delay = std.math.minInt(i64), .served = true },
531 .{ .delay = 1, .served = false },
532 .{ .delay = 60_000, .served = false },
533 }) |case| {
534 try testing.expectEqual(case.served, servesImmediately(.{
535 .title = "",
536 .body = "",
537 .id = "x",
538 .delay = case.delay,
539 }));
540 }
541}
542
543test "an explicit null is text for a string and a fallback for the delay" {
544 // The org.json split every migrated action reproduces: optString reaches
545 // for JSONObject.NULL.toString(), optLong does not.
546 const n = (try parsedNotification(
547 \\{"title":null,"body":null,"id":null,"delay":null}
548 , "1700000000000")).?;
549 defer freeNotification(n);
550
551 try testing.expectEqualStrings("null", n.title);
552 try testing.expectEqualStrings("null", n.body);
553 // Including the id, which then becomes the notification's identity — a
554 // page sending {id: null} twice replaces its own notification.
555 try testing.expectEqualStrings("null", n.id);
556 try testing.expectEqual(@as(i64, 0), n.delay);
557}
558
559test "a shape only org.json would coerce is handed back" {
560 for ([_][]const u8{
561 \\{"title":1.5}
562 ,
563 \\{"id":7}
564 ,
565 \\{"delay":"1000"}
566 ,
567 \\{"delay":1.5}
568 ,
569 \\{"body":true}
570 ,
571 \\[]
572 ,
573 \\"Order shipped"
574 ,
575 }) |payload| {
576 if (try parsedNotification(payload, "1700000000000")) |n| {
577 freeNotification(n);
578 std.debug.print("payload was served rather than handed back: {s}\n", .{payload});
579 return error.CoercedShapeAccepted;
580 }
581 }
582}
583
584test "the channel is the shim's, and its constants are the folded literals" {
585 // The id has to match `CraftBridge.notificationChannelId`, or a
586 // notification posts to a channel the user has never seen and cannot have
587 // configured — and on API 26+ one that does not exist is dropped silently.
588 try testing.expectEqualStrings("craft_notifications", channel_id);
589 try testing.expectEqualStrings("Craft Notifications", channel_name);
590 try testing.expectEqualStrings("Notifications from Craft app", channel_description);
591
592 // NotificationManager.IMPORTANCE_DEFAULT and Build.VERSION_CODES.O.
593 try testing.expectEqual(@as(i32, 3), importance_default);
594 try testing.expectEqual(@as(i32, 26), version_o);
595}
596
597test "scheduleNotification names its action and globals as the shim does" {
598 try testing.expectEqualStrings("scheduleNotification", A.schedule_notification);
599 try testing.expectEqualStrings("_craftNotifResolve", schedule_resolve_global);
600 try testing.expectEqualStrings("_craftNotifReject", schedule_reject_global);
601}
packages/zig/test/android_conformance_test.zigmodified+4-3
Changes to packages/zig/test/android_conformance_test.zig
@@ -46,7 +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"),
49 @embedFile("src/bridge_android_notifications.zig"),
5050 @embedFile("src/bridge_android_calendar.zig"),
5151 @embedFile("src/bridge_android_db.zig"),
5252 @embedFile("src/bridge_android_shareditem.zig"),
@@ -76,8 +76,9 @@ const zig_sources = [_][]const u8{
7676/// widget pair, the first to take a constant across from the Kotlin because
7777/// deriving it here would be silently wrong under an applicationIdSuffix; 73
7878/// with the shortcut pair, whose two halves disagree about what to do below
79/// API 25 and are ported disagreeing.
80const max_not_yet_migrated: usize = 73;
79/// API 25 and are ported disagreeing; 72 with the immediate half of
80/// scheduleNotification, whose delayed half needs a Runnable.
81const max_not_yet_migrated: usize = 72;
8182
8283/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
8384///