ReviewOS

also looking at this

craft-native/craft

feat(android): deleteCalendarEvent — the first action to answer through the channel

#155
Merged glennmichael123 wants to merge feat/android-delete-calendar-event into main
7 files +288 -3
packages/android/templates/CraftBridge.kt.templatemodified+4-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2350,6 +2350,10 @@ class CraftBridge(
23502350
23512351 @JavascriptInterface
23522352 fun deleteCalendarEvent(eventId: String) {
2353 // Zig answers through the reply channel, so returning here is what
2354 // stops the promise being settled twice.
2355 if (CraftNative.deleteCalendarEvent(activity, eventId)) return
2356
23532357 try {
23542358 val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId.toLong())
23552359 activity.contentResolver.delete(uri, null, null)
packages/android/templates/CraftNative.kt.templatemodified+17-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -107,6 +107,7 @@ object CraftNative {
107107 private external fun nativeVibrate(activity: Activity, patternJson: String): Boolean
108108 private external fun nativeCancelNotification(activity: Activity, id: String): Boolean
109109 private external fun nativeCancelAllNotifications(activity: Activity): Boolean
110 private external fun nativeDeleteCalendarEvent(activity: Activity, eventId: String): Boolean
110111
111112 /**
112113 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -333,4 +334,20 @@ object CraftNative {
333334 false
334335 }
335336 }
337
338 /**
339 * Returns whether Zig *took* the action, not whether the delete worked.
340 *
341 * The first native here that answers asynchronously: the outcome reaches
342 * the page through the reply channel, so true means "do not also run the
343 * Kotlin" and nothing more. Running both would settle the promise twice.
344 */
345 fun deleteCalendarEvent(activity: Activity, eventId: String): Boolean {
346 if (!isAvailable) return false
347 return try {
348 nativeDeleteCalendarEvent(activity, eventId)
349 } catch (e: UnsatisfiedLinkError) {
350 false
351 }
352 }
336353}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -455,6 +455,9 @@ pub fn build(b: *std.Build) void {
455455 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_notifcancel.zig", .{
456456 .root_source_file = b.path("src/bridge_android_notifcancel.zig"),
457457 });
458 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_calendar.zig", .{
459 .root_source_file = b.path("src/bridge_android_calendar.zig"),
460 });
458461 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
459462
460463 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+56-1
Changes to packages/zig/src/android_dispatch.zig
@@ -45,6 +45,7 @@ const securestore = ;
4545const haptics = ;
4646const notifcancel = ;
4747const events = ;
48const calendar = ;
4849
4950const Jni = jni.Jni;
5051
@@ -217,6 +218,14 @@ const natives = [_]jni.JNINativeMethod{
217218 .signature = "(Landroid/app/Activity;)Z",
218219 .fnPtr = @ptrCast(&nativeCancelAllNotifications),
219220 },
221 // The first action that answers through the reply channel rather than by
222 // returning. The boolean still means "Zig served it" — the *result* goes
223 // to the page separately, which is exactly the shape iOS's seam has.
224 .{
225 .name = "nativeDeleteCalendarEvent",
226 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
227 .fnPtr = @ptrCast(&nativeDeleteCalendarEvent),
228 },
220229};
221230
222231/// How binding went. A value rather than a log line, so every path is
@@ -587,6 +596,52 @@ fn nativeCancelAllNotifications(env: jni.JNIEnv, _: jni.jobject, activity: jni.j
587596 return jni.JNI_TRUE;
588597}
589598
599/// `nativeDeleteCalendarEvent(activity, eventId)`.
600///
601/// Returns whether Zig took the action, not whether the delete succeeded. The
602/// outcome reaches the page through the reply channel, so a `true` here means
603/// "do not also run the Kotlin" and nothing more — the same distinction
604/// `ios_dispatch` draws between claiming an action and answering it.
605fn nativeDeleteCalendarEvent(
606 env: jni.JNIEnv,
607 _: jni.jobject,
608 activity: jni.jobject,
609 event_id: jni.jstring,
610) callconv(.c) jni.jboolean {
611 const j = Jni.init(env);
612 var arena = std.heap.ArenaAllocator.init(backing);
613 defer arena.deinit();
614 const allocator = arena.allocator();
615
616 const text = j.stringToUtf8(allocator, event_id) catch return jni.JNI_FALSE;
617
618 const id = calendar.parseEventId(text) orelse {
619 // What the shim's NumberFormatException produces, with the message it
620 // produces — but escaped, so an id containing a quote rejects instead
621 // of hanging the promise. See #154.
622 var message: std.ArrayListUnmanaged(u8) = .empty;
623 defer message.deinit(allocator);
624 message.appendSlice(allocator, "For input string: \"") catch return jni.JNI_FALSE;
625 message.appendSlice(allocator, text) catch return jni.JNI_FALSE;
626 message.append(allocator, '"') catch return jni.JNI_FALSE;
627
628 calendar.rejectWith(allocator, message.items) catch return jni.JNI_FALSE;
629 return jni.JNI_TRUE;
630 };
631
632 calendar.deleteEvent(j, activity, id) catch |err| {
633 // A SecurityException for a missing WRITE_CALENDAR permission lands
634 // here, as it lands in the shim's catch. The error name rather than
635 // the Java message: the throwable was already described to logcat and
636 // cleared by `Jni.check`, so its text is gone by now.
637 calendar.rejectWith(allocator, @errorName(err)) catch {};
638 return jni.JNI_TRUE;
639 };
640
641 events.settle(allocator, calendar.resolve_global, "true") catch return jni.JNI_FALSE;
642 return jni.JNI_TRUE;
643}
644
590645// =============================================================================
591646// Tests
592647// =============================================================================
@@ -698,7 +753,7 @@ test "the registered natives name methods the Kotlin actually declares" {
698753 // A descriptor is checked by the JVM at registration, so a wrong one fails
699754 // at load rather than at call — but only if the *name* matches something.
700755 // These two strings are the contract with CraftBridge.kt.
701 try testing.expectEqual(@as(usize, 16), natives.len);
756 try testing.expectEqual(@as(usize, 17), natives.len);
702757 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
703758
704759 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_calendar.zigadded+196-0
Changes to packages/zig/src/bridge_android_calendar.zig
@@ -0,0 +1,196 @@
1//! `deleteCalendarEvent` on Android — the first action here that answers
2//! through the reply channel rather than by returning.
3//!
4//! ## What the shim does, and the one thing this does not reproduce
5//!
6//! The Kotlin builds a content URI from the event id, deletes through the
7//! `ContentResolver`, and resolves `true`; on any exception it rejects with
8//! the message. That is all ported.
9//!
10//! What is not ported is how it *sends* the rejection:
11//!
12//! "window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')"
13//!
14//! The message goes into a single-quoted JavaScript string with no escaping,
15//! and `eventId.toLong()` puts the caller's own text in that message —
16//! `NumberFormatException: For input string: "1'x"`. A `'` in the id therefore
17//! produces a script that does not parse, `evaluateJavascript` runs nothing,
18//! and **neither the resolve nor the reject fires**. The promise never
19//! settles, and the injected JS builds it by hand with no timeout, so the page
20//! waits forever with nothing in the console.
21//!
22//! Here the payload is built as JSON through `bridge_error.appendJsonEscaped`,
23//! so the same id rejects normally. That is a divergence in the page's favour
24//! and it is deliberate; the shim keeps the bug on the twenty other paths
25//! until #154 is fixed.
26//!
27//! ## `toLong()` is reproduced exactly, including what it refuses
28//!
29//! `Long.parseLong` accepts an optional sign and decimal digits, and nothing
30//! else — no whitespace, no underscores, no hex, no `+1_000`. Zig's
31//! `parseInt(i64, s, 10)` is more permissive about underscores, so the digits
32//! are checked before parsing. An id the shim would reject must reject here,
33//! or Zig would delete an event the shim would have refused to look up.
34
35const std = ;
36const jni = ;
37const events = ;
38const bridge_error = ;
39
40const Jni = jni.Jni;
41const jobject = jni.jobject;
42
43pub const A = struct {
44 pub const delete_calendar_event = "deleteCalendarEvent";
45};
46
47/// The globals the injected JS assigns for this action.
48pub const resolve_global = "_craftDeleteEventResolve";
49pub const reject_global = "_craftDeleteEventReject";
50
51/// `Long.parseLong(text)`, or null where Java would throw.
52///
53/// Deliberately stricter than `std.fmt.parseInt`, which accepts `1_000`.
54/// Java does not, and an id this accepted but the shim would not is an id Zig
55/// would act on and the shim would refuse.
56pub fn parseEventId(text: []const u8) ?i64 {
57 if (text.len == 0) return null;
58
59 var digits = text;
60 if (digits[0] == '+' or digits[0] == '-') digits = digits[1..];
61 if (digits.len == 0) return null;
62
63 for (digits) |c| {
64 if (!std.ascii.isDigit(c)) return null;
65 }
66 return std.fmt.parseInt(i64, text, 10) catch null;
67}
68
69/// `contentResolver.delete(ContentUris.withAppendedId(Events.CONTENT_URI, id), null, null)`.
70pub fn deleteEvent(j: Jni, activity: jobject, id: i64) !void {
71 try j.pushLocalFrame(16);
72 defer _ = j.popLocalFrame(null);
73
74 // `CalendarContract$Events` — a nested class, so the binary name takes the
75 // dollar. `android/provider/CalendarContract/Events` finds nothing.
76 const events_cls = try j.findClass("android/provider/CalendarContract$Events");
77 const base_uri = try j.staticObjectField(
78 events_cls,
79 try j.staticFieldId(events_cls, "CONTENT_URI", "Landroid/net/Uri;"),
80 );
81
82 const content_uris_cls = try j.findClass("android/content/ContentUris");
83 const uri = try j.callStaticObjectMethodA(
84 content_uris_cls,
85 try j.staticMethodId(
86 content_uris_cls,
87 "withAppendedId",
88 "(Landroid/net/Uri;J)Landroid/net/Uri;",
89 ),
90 &.{ .{ .l = base_uri }, .{ .j = id } },
91 );
92
93 const activity_cls = try j.objectClass(activity);
94 const resolver = try j.callObjectMethod(
95 activity,
96 try j.methodId(activity_cls, "getContentResolver", "()Landroid/content/ContentResolver;"),
97 );
98
99 // The row count is discarded, as the Kotlin discards it. Deleting an event
100 // that is not there returns 0 and is not an error on either side — a page
101 // removing something already gone has done nothing wrong.
102 const resolver_cls = try j.objectClass(resolver);
103 _ = try j.callIntMethodA(
104 resolver,
105 try j.methodId(
106 resolver_cls,
107 "delete",
108 "(Landroid/net/Uri;Ljava/lang/String;[Ljava/lang/String;)I",
109 ),
110 &.{ .{ .l = uri }, .{ .l = null }, .{ .l = null } },
111 );
112}
113
114/// Reject with `message`, escaped.
115///
116/// The escaping is the whole point of this function existing rather than the
117/// call being inline — see the module comment and #154.
118pub fn rejectWith(allocator: std.mem.Allocator, message: []const u8) !void {
119 const payload = try rejectPayload(allocator, message);
120 defer allocator.free(payload);
121 try events.settle(allocator, reject_global, payload);
122}
123
124/// `message` as a JSON string, quotes included.
125///
126/// Split out so the test exercises this rather than a copy of it. An earlier
127/// version inlined the escaping here and asserted on the same three lines
128/// rewritten in the test — which passed happily when the escaping was removed
129/// from the code, because the test was not calling the code.
130pub fn rejectPayload(allocator: std.mem.Allocator, message: []const u8) ![]u8 {
131 var payload: std.ArrayListUnmanaged(u8) = .empty;
132 errdefer payload.deinit(allocator);
133
134 // The quotes are this caller's job: `appendJsonEscaped` escapes the
135 // contents and writes no delimiters.
136 try payload.append(allocator, '"');
137 try bridge_error.appendJsonEscaped(allocator, &payload, message);
138 try payload.append(allocator, '"');
139
140 return payload.toOwnedSlice(allocator);
141}
142
143// =============================================================================
144// Tests
145// =============================================================================
146
147const testing = std.testing;
148
149test "an id parses exactly where Long.parseLong would" {
150 try testing.expectEqual(@as(i64, 42), parseEventId("42").?);
151 try testing.expectEqual(@as(i64, -42), parseEventId("-42").?);
152 try testing.expectEqual(@as(i64, 42), parseEventId("+42").?);
153 try testing.expectEqual(@as(i64, 0), parseEventId("0").?);
154 try testing.expectEqual(@as(i64, 9_223_372_036_854_775_807), parseEventId("9223372036854775807").?);
155}
156
157test "an id Java would refuse is refused here too" {
158 // Each of these throws NumberFormatException on the shim, so each must
159 // reject here. An id Zig accepted and the shim did not is an id Zig would
160 // delete an event for that the shim would never have looked up.
161 for ([_][]const u8{
162 "", "abc", "1'x", " 42", "42 ", "4 2", "0x2a", "1e3", "42L", "+", "-",
163 "٤٢",
164 // Zig's parseInt accepts this and Java does not.
165 "1_000",
166 // Past Long.MAX_VALUE.
167 "9223372036854775808",
168 }) |bad| {
169 try testing.expect(parseEventId(bad) == null);
170 }
171}
172
173test "a rejection message with a quote is escaped rather than emitted raw" {
174 // The bug this action does not reproduce. On the shim, an id of `1'x`
175 // produces `…Reject('For input string: "1'x"')`, which does not parse — so
176 // evaluateJavascript runs nothing and the promise never settles.
177 const payload = try rejectPayload(testing.allocator, "For input string: \"1'x\"");
178 defer testing.allocator.free(payload);
179
180 // Valid JSON, and it round-trips to exactly the original message.
181 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
182 defer parsed.deinit();
183 try testing.expectEqualStrings("For input string: \"1'x\"", parsed.value.string);
184
185 // And the apostrophe survives as itself rather than as an escape, which is
186 // what makes the emitted script parse where the shim's does not.
187 try testing.expect(std.mem.indexOf(u8, payload, "'") != null);
188}
189
190test "the action name and its globals match the shim exactly" {
191 // The globals are assigned by the injected JS, and a rename on either side
192 // is a promise that never settles.
193 try testing.expectEqualStrings("deleteCalendarEvent", A.delete_calendar_event);
194 try testing.expectEqualStrings("_craftDeleteEventResolve", resolve_global);
195 try testing.expectEqualStrings("_craftDeleteEventReject", reject_global);
196}
packages/zig/src/jni_runtime.zigmodified+8-0
Changes to packages/zig/src/jni_runtime.zig
@@ -624,6 +624,14 @@ pub const Jni = struct {
624624 return result;
625625 }
626626
627 pub fn callIntMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!jint {
628 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) jint =
629 @ptrCast(self.table().CallIntMethodA orelse return JniError.NotFound);
630 const result = call(self.env, obj, id, args.ptr);
631 try self.check();
632 return result;
633 }
634
627635 pub fn callBooleanMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!bool {
628636 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) jboolean =
629637 @ptrCast(self.table().CallBooleanMethodA orelse return JniError.NotFound);
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -47,6 +47,7 @@ const zig_sources = [_][]const u8{
4747 @embedFile("src/bridge_android_securestore.zig"),
4848 @embedFile("src/bridge_android_haptics.zig"),
4949 @embedFile("src/bridge_android_notifcancel.zig"),
50 @embedFile("src/bridge_android_calendar.zig"),
5051};
5152
5253/// How many spec actions Zig does not serve yet.
@@ -61,8 +62,9 @@ const zig_sources = [_][]const u8{
6162/// getNetworkStatus, which iOS declares unavailable and Android can answer;
6263/// 91 with the secure-storage quartet, the first to take a Kotlin-held object
6364/// rather than the Activity; 89 with haptic and vibrate; 87 with the
64/// notification cancels.
65const max_not_yet_migrated: usize = 87;
65/// notification cancels; 86 with deleteCalendarEvent, the first to answer
66/// through the reply channel rather than by returning.
67const max_not_yet_migrated: usize = 86;
6668
6769/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
6870///