| @@ -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 | + |
| 32 | +const std = ; |
| 33 | +const jni = ; |
| 34 | + |
| 35 | +const Jni = jni.Jni; |
| 36 | +const jobject = jni.jobject; |
| 37 | + |
| 38 | +pub const A = struct { |
| 39 | + pub const cancel_notification = "cancelNotification"; |
| 40 | + pub const cancel_all_notifications = "cancelAllNotifications"; |
| 41 | +}; |
| 42 | + |
| 43 | +/// `activity.getSystemService(Context.NOTIFICATION_SERVICE)`. |
| 44 | +fn 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. |
| 64 | +pub 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()`. |
| 83 | +pub 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 | + |
| 104 | +const testing = std.testing; |
| 105 | + |
| 106 | +var fake_storage: [8]u8 = undefined; |
| 107 | +var fake_cancelled_with: jni.jint = 0; |
| 108 | +var fake_cancel_all_called = false; |
| 109 | +var fake_hash: jni.jint = 0; |
| 110 | + |
| 111 | +fn cobj(tag: usize) jobject { |
| 112 | + return @ptrCast(&fake_storage[tag]); |
| 113 | +} |
| 114 | +fn cName(id: ?*anyopaque) []const u8 { |
| 115 | + return std.mem.span(@as([*:0]const u8, @ptrCast(id.?))); |
| 116 | +} |
| 117 | +fn cFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass { |
| 118 | + return cobj(0); |
| 119 | +} |
| 120 | +fn cObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass { |
| 121 | + return cobj(0); |
| 122 | +} |
| 123 | +fn cStaticFieldId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID { |
| 124 | + return cobj(1); |
| 125 | +} |
| 126 | +fn cStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject { |
| 127 | + return cobj(2); |
| 128 | +} |
| 129 | +fn cMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID { |
| 130 | + return @ptrCast(@constCast(name)); |
| 131 | +} |
| 132 | +fn 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 | +} |
| 138 | +fn cCallObjectMethodA(_: jni.JNIEnv, _: jobject, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject { |
| 139 | + return cobj(3); |
| 140 | +} |
| 141 | +fn 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 | +} |
| 146 | +fn cExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject { |
| 147 | + return null; |
| 148 | +} |
| 149 | +fn cPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint { |
| 150 | + return 0; |
| 151 | +} |
| 152 | +fn cPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject { |
| 153 | + return keep; |
| 154 | +} |
| 155 | + |
| 156 | +fn 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 | + |
| 173 | +test "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 | + |
| 193 | +test "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 | + |
| 205 | +test "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 | + |
| 214 | +test "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 | +} |