| @@ -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 | + |
| 46 | +const std = ; |
| 47 | +const jni = ; |
| 48 | + |
| 49 | +const Jni = jni.Jni; |
| 50 | +const jobject = jni.jobject; |
| 51 | + |
| 52 | +pub 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)`. |
| 59 | +fn 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. |
| 79 | +pub 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()`. |
| 98 | +pub 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. |
| 116 | +pub const schedule_resolve_global = "_craftNotifResolve"; |
| 117 | +pub const schedule_reject_global = "_craftNotifReject"; |
| 118 | + |
| 119 | +/// `CraftBridge.notificationChannelId`. |
| 120 | +const channel_id = "craft_notifications"; |
| 121 | +const channel_name = "Craft Notifications"; |
| 122 | +const 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. |
| 127 | +const importance_default: i32 = 3; |
| 128 | +const version_o: i32 = 26; |
| 129 | + |
| 130 | +/// What the page sent, in the shape `NotificationData` declares. |
| 131 | +pub 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. |
| 144 | +pub 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 | + |
| 158 | +fn 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 | + |
| 169 | +fn 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. |
| 183 | +pub fn servesImmediately(notification: Notification) bool { |
| 184 | + return notification.delay <= 0; |
| 185 | +} |
| 186 | + |
| 187 | +/// `Build.VERSION.SDK_INT`. |
| 188 | +pub 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. |
| 201 | +pub 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. |
| 244 | +pub 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. |
| 339 | +pub 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 | + |
| 360 | +const testing = std.testing; |
| 361 | + |
| 362 | +var fake_storage: [8]u8 = undefined; |
| 363 | +var fake_cancelled_with: jni.jint = 0; |
| 364 | +var fake_cancel_all_called = false; |
| 365 | +var fake_hash: jni.jint = 0; |
| 366 | + |
| 367 | +fn cobj(tag: usize) jobject { |
| 368 | + return @ptrCast(&fake_storage[tag]); |
| 369 | +} |
| 370 | +fn cName(id: ?*anyopaque) []const u8 { |
| 371 | + return std.mem.span(@as([*:0]const u8, @ptrCast(id.?))); |
| 372 | +} |
| 373 | +fn cFindClass(_: jni.JNIEnv, _: [*:0]const u8) callconv(.c) jni.jclass { |
| 374 | + return cobj(0); |
| 375 | +} |
| 376 | +fn cObjectClass(_: jni.JNIEnv, _: jobject) callconv(.c) jni.jclass { |
| 377 | + return cobj(0); |
| 378 | +} |
| 379 | +fn cStaticFieldId(_: jni.JNIEnv, _: jni.jclass, _: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jfieldID { |
| 380 | + return cobj(1); |
| 381 | +} |
| 382 | +fn cStaticObjectField(_: jni.JNIEnv, _: jni.jclass, _: jni.jfieldID) callconv(.c) jobject { |
| 383 | + return cobj(2); |
| 384 | +} |
| 385 | +fn cMethodId(_: jni.JNIEnv, _: jni.jclass, name: [*:0]const u8, _: [*:0]const u8) callconv(.c) jni.jmethodID { |
| 386 | + return @ptrCast(@constCast(name)); |
| 387 | +} |
| 388 | +fn 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 | +} |
| 394 | +fn cCallObjectMethodA(_: jni.JNIEnv, _: jobject, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject { |
| 395 | + return cobj(3); |
| 396 | +} |
| 397 | +fn 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 | +} |
| 402 | +fn cExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject { |
| 403 | + return null; |
| 404 | +} |
| 405 | +fn cPush(_: jni.JNIEnv, _: jni.jint) callconv(.c) jni.jint { |
| 406 | + return 0; |
| 407 | +} |
| 408 | +fn cPop(_: jni.JNIEnv, keep: jobject) callconv(.c) jobject { |
| 409 | + return keep; |
| 410 | +} |
| 411 | + |
| 412 | +fn 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 | + |
| 429 | +test "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 | + |
| 449 | +test "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 | + |
| 461 | +test "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 | + |
| 470 | +test "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 | + |
| 477 | +fn 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 | + |
| 493 | +fn freeNotification(n: Notification) void { |
| 494 | + testing.allocator.free(n.title); |
| 495 | + testing.allocator.free(n.body); |
| 496 | + testing.allocator.free(n.id); |
| 497 | +} |
| 498 | + |
| 499 | +test "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 | + |
| 511 | +test "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 | + |
| 524 | +test "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 | + |
| 543 | +test "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 | + |
| 559 | +test "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 | + |
| 584 | +test "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 | + |
| 597 | +test "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 | +} |