ReviewOS

also looking at this

craft-native/craft

feat(android): getCalendarEvents and createCalendarEvent in Zig — ratchet 86 → 84

#158
Merged glennmichael123 wants to merge feat/android-calendar-events into main
7 files +1167 -20
packages/android/templates/CraftBridge.kt.templatemodified+8-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2274,6 +2274,10 @@ class CraftBridge(
22742274
22752275 @JavascriptInterface
22762276 fun getCalendarEvents(startDateMs: Long, endDateMs: Long) {
2277 // Zig answers through the reply channel, so returning here is what
2278 // stops the promise being settled twice.
2279 if (CraftNative.getCalendarEvents(activity, startDateMs, endDateMs)) return
2280
22772281 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CALENDAR)
22782282 != PackageManager.PERMISSION_GRANTED) {
22792283 ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CALENDAR), REQUEST_CALENDAR)
@@ -2327,6 +2331,10 @@ class CraftBridge(
23272331
23282332 @JavascriptInterface
23292333 fun createCalendarEvent(eventJson: String) {
2334 // Zig answers through the reply channel, so returning here is what
2335 // stops the promise being settled twice.
2336 if (CraftNative.createCalendarEvent(activity, eventJson)) return
2337
23302338 if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_CALENDAR)
23312339 != PackageManager.PERMISSION_GRANTED) {
23322340 ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_CALENDAR), REQUEST_CALENDAR)
packages/android/templates/CraftNative.kt.templatemodified+35-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -108,6 +108,8 @@ object CraftNative {
108108 private external fun nativeCancelNotification(activity: Activity, id: String): Boolean
109109 private external fun nativeCancelAllNotifications(activity: Activity): Boolean
110110 private external fun nativeDeleteCalendarEvent(activity: Activity, eventId: String): Boolean
111 private external fun nativeGetCalendarEvents(activity: Activity, startDateMs: Long, endDateMs: Long): Boolean
112 private external fun nativeCreateCalendarEvent(activity: Activity, eventJson: String): Boolean
111113
112114 /**
113115 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -350,4 +352,37 @@ object CraftNative {
350352 false
351353 }
352354 }
355
356 /**
357 * Returns whether Zig took the action, not whether the read found anything.
358 *
359 * An empty calendar still answers — with `[]`, through the reply channel —
360 * so a false here means Zig did not run, never that there was nothing to
361 * report.
362 */
363 fun getCalendarEvents(activity: Activity, startDateMs: Long, endDateMs: Long): Boolean {
364 if (!isAvailable) return false
365 return try {
366 nativeGetCalendarEvents(activity, startDateMs, endDateMs)
367 } catch (e: UnsatisfiedLinkError) {
368 false
369 }
370 }
371
372 /**
373 * Returns whether Zig took the action.
374 *
375 * A false here is routine rather than exceptional: Zig serves the shape
376 * `NewCalendarEvent` declares and hands anything else back, because
377 * `org.json` coerces where a strict parser refuses. The Kotlin below is
378 * what answers those.
379 */
380 fun createCalendarEvent(activity: Activity, eventJson: String): Boolean {
381 if (!isAvailable) return false
382 return try {
383 nativeCreateCalendarEvent(activity, eventJson)
384 } catch (e: UnsatisfiedLinkError) {
385 false
386 }
387 }
353388}
packages/zig/src/android_dispatch.zigmodified+148-1
Changes to packages/zig/src/android_dispatch.zig
@@ -36,6 +36,7 @@
3636const std = ;
3737const builtin = ;
3838const jni = ;
39const permissions = ;
3940const device = ;
4041const system = ;
4142const clipboard = ;
@@ -63,6 +64,17 @@ const Jni = jni.Jni;
6364/// individually on the way out.
6465const backing = std.heap.page_allocator;
6566
67/// `CraftBridge.REQUEST_CALENDAR`, the request code the shim passes.
68///
69/// It has to match, because it is the only thing that would tell an
70/// `onRequestPermissionsResult` which request it is answering — and if the
71/// shim ever grows one, a code Zig invented would arrive as a request nothing
72/// asked for.
73const request_calendar: i32 = 1005;
74
75/// The shim's default event length: `System.currentTimeMillis() + 3600000`.
76const one_hour_ms: i64 = 60 * 60 * 1000;
77
6678/// The VM, captured at load so a later callback on a framework thread can ask
6779/// it for that thread's `JNIEnv`.
6880///
@@ -226,6 +238,16 @@ const natives = [_]jni.JNINativeMethod{
226238 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
227239 .fnPtr = @ptrCast(&nativeDeleteCalendarEvent),
228240 },
241 .{
242 .name = "nativeGetCalendarEvents",
243 .signature = "(Landroid/app/Activity;JJ)Z",
244 .fnPtr = @ptrCast(&nativeGetCalendarEvents),
245 },
246 .{
247 .name = "nativeCreateCalendarEvent",
248 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
249 .fnPtr = @ptrCast(&nativeCreateCalendarEvent),
250 },
229251};
230252
231253/// How binding went. A value rather than a log line, so every path is
@@ -642,6 +664,131 @@ fn nativeDeleteCalendarEvent(
642664 return jni.JNI_TRUE;
643665}
644666
667/// `nativeGetCalendarEvents(activity, startDateMs, endDateMs)`.
668///
669/// Reads the calendar and settles `_craftCalendarResolve` with the array.
670///
671/// ## Where this returns false
672///
673/// Only before anything has been sent. A failure after the reply channel has
674/// been used would settle the page's promise twice, since a false sends the
675/// shim down the same path — so the JNI work runs to completion or reports
676/// nothing at all, and the shim answers instead.
677///
678/// That includes the case where the query itself throws. The shim wraps none
679/// of `getCalendarEvents` in a try/catch, so a `SecurityException` from a
680/// permission revoked between the check and the query propagates out of the
681/// `@JavascriptInterface` method and the promise hangs. Rejecting here would
682/// be kinder and would also be a divergence nothing records, so this falls
683/// through and lets the shim behave as it does. See #157.
684fn nativeGetCalendarEvents(
685 env: jni.JNIEnv,
686 _: jni.jobject,
687 activity: jni.jobject,
688 start_date_ms: jni.jlong,
689 end_date_ms: jni.jlong,
690) callconv(.c) jni.jboolean {
691 const j = Jni.init(env);
692 var arena = std.heap.ArenaAllocator.init(backing);
693 defer arena.deinit();
694 const allocator = arena.allocator();
695
696 const granted = permissions.isGranted(j, activity, permissions.read_calendar) catch |err| {
697 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
698 return jni.JNI_FALSE;
699 };
700
701 if (!granted) {
702 // The shim asks and rejects in the same breath: the answer arrives at
703 // `onRequestPermissionsResult`, which nothing here implements, so the
704 // request is what makes a *later* call work rather than this one.
705 permissions.request(j, activity, permissions.read_calendar, request_calendar) catch |err| {
706 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
707 return jni.JNI_FALSE;
708 };
709 events.settle(allocator, calendar.list_reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
710 return jni.JNI_TRUE;
711 }
712
713 const now = calendar.currentTimeMillis(j) catch |err| {
714 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
715 return jni.JNI_FALSE;
716 };
717 const window = calendar.windowFor(start_date_ms, end_date_ms, now);
718
719 var payload: std.ArrayListUnmanaged(u8) = .empty;
720 defer payload.deinit(allocator);
721 calendar.queryEvents(j, allocator, activity, window, &payload) catch |err| {
722 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
723 return jni.JNI_FALSE;
724 };
725
726 events.settle(allocator, calendar.list_resolve_global, payload.items) catch return jni.JNI_FALSE;
727 return jni.JNI_TRUE;
728}
729
730/// `nativeCreateCalendarEvent(activity, eventJson)`.
731///
732/// Returns false — leaving the shim to serve it — for any payload outside the
733/// shape `NewCalendarEvent` declares. `org.json` coerces a `startDate` sent as
734/// a string and a `title` sent as a number, and the string forms it produces
735/// for a `Double` are not something to reproduce from memory. Declining is
736/// cheap here because nothing has been sent yet.
737fn nativeCreateCalendarEvent(
738 env: jni.JNIEnv,
739 _: jni.jobject,
740 activity: jni.jobject,
741 event_json: jni.jstring,
742) callconv(.c) jni.jboolean {
743 const j = Jni.init(env);
744 var arena = std.heap.ArenaAllocator.init(backing);
745 defer arena.deinit();
746 const allocator = arena.allocator();
747
748 const granted = permissions.isGranted(j, activity, permissions.write_calendar) catch |err| {
749 std.log.warn("craft: createCalendarEvent fell through to the shim ({s})", .{@errorName(err)});
750 return jni.JNI_FALSE;
751 };
752
753 if (!granted) {
754 permissions.request(j, activity, permissions.write_calendar, request_calendar) catch |err| {
755 std.log.warn("craft: createCalendarEvent fell through to the shim ({s})", .{@errorName(err)});
756 return jni.JNI_FALSE;
757 };
758 events.settle(allocator, calendar.create_reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
759 return jni.JNI_TRUE;
760 }
761
762 const text = j.stringToUtf8(allocator, event_json) catch return jni.JNI_FALSE;
763
764 // `JSONTokener` is lenient where `std.json` is strict — unquoted keys,
765 // single quotes, a trailing comma. Everything strict JSON accepts it
766 // accepts too, so the difference only ever sends work back to the shim.
767 var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch
768 return jni.JNI_FALSE;
769 defer parsed.deinit();
770
771 const start_default = calendar.currentTimeMillis(j) catch return jni.JNI_FALSE;
772 const end_default = calendar.currentTimeMillis(j) catch return jni.JNI_FALSE;
773 const event = calendar.parseNewEvent(
774 parsed.value,
775 start_default,
776 end_default +% one_hour_ms,
777 ) orelse return jni.JNI_FALSE;
778
779 const id = calendar.insertEvent(j, allocator, activity, event) catch |err| {
780 // The shim catches here and rejects with the exception's message. The
781 // throwable was described to logcat and cleared by `Jni.check` before
782 // this point, so the error name is what is left to say.
783 calendar.rejectOn(allocator, calendar.create_reject_global, @errorName(err)) catch {};
784 return jni.JNI_TRUE;
785 };
786
787 const payload = calendar.jsonString(allocator, id) catch return jni.JNI_FALSE;
788 events.settle(allocator, calendar.create_resolve_global, payload) catch return jni.JNI_FALSE;
789 return jni.JNI_TRUE;
790}
791
645792// =============================================================================
646793// Tests
647794// =============================================================================
@@ -753,7 +900,7 @@ test "the registered natives name methods the Kotlin actually declares" {
753900 // A descriptor is checked by the JVM at registration, so a wrong one fails
754901 // at load rather than at call — but only if the *name* matches something.
755902 // These two strings are the contract with CraftBridge.kt.
756 try testing.expectEqual(@as(usize, 17), natives.len);
903 try testing.expectEqual(@as(usize, 19), natives.len);
757904 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
758905
759906 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/android_permissions.zigadded+98-0
Changes to packages/zig/src/android_permissions.zig
@@ -0,0 +1,98 @@
1//! Runtime permissions, the two calls every guarded action starts with.
2//!
3//! The Kotlin reaches these through AndroidX:
4//!
5//! ```kotlin
6//! if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CALENDAR)
7//! != PackageManager.PERMISSION_GRANTED) {
8//! ActivityCompat.requestPermissions(activity, arrayOf(...), REQUEST_CALENDAR)
9//! ```
10//!
11//! Zig calls the framework methods those two delegate to. That is not a
12//! shortcut around a dependency — `ContextCompat.checkSelfPermission` *is*
13//! `Context.checkSelfPermission` from API 23, and `ActivityCompat`'s
14//! `requestPermissions` is `Activity.requestPermissions` from the same level,
15//! with the pre-23 branches dead on any modern `minSdk`. Going through the
16//! framework also means no `androidx.core` on the JNI side, which matters
17//! because `libcraft.so` is built with no NDK and links nothing.
18//!
19//! ## Which thread
20//!
21//! `requestPermissions` is documented as main-thread, and this is called from
22//! the WebView's JavaBridge thread — because the shim calls it from there too.
23//! Hoisting it onto the main looper here would be a divergence, and a silent
24//! one: the two would then differ in when the dialog appears relative to the
25//! rejection the page already received.
26
27const std = ;
28const jni = ;
29
30const Jni = jni.Jni;
31const jobject = jni.jobject;
32
33/// `PackageManager.PERMISSION_GRANTED`.
34///
35/// A compile-time constant in Java, so the shim's DEX holds the literal `0`
36/// rather than a field read — which is why it is a literal here as well.
37pub const granted: i32 = 0;
38
39pub const read_calendar = "android.permission.READ_CALENDAR";
40pub const write_calendar = "android.permission.WRITE_CALENDAR";
41
42/// `activity.checkSelfPermission(permission) == PERMISSION_GRANTED`.
43pub fn isGranted(j: Jni, activity: jobject, permission: [*:0]const u8) !bool {
44 try j.pushLocalFrame(8);
45 defer _ = j.popLocalFrame(null);
46
47 const name = try j.newStringUtf(permission);
48 const activity_cls = try j.objectClass(activity);
49 const result = try j.callIntMethodA(
50 activity,
51 try j.methodId(activity_cls, "checkSelfPermission", "(Ljava/lang/String;)I"),
52 &.{.{ .l = name }},
53 );
54 return result == granted;
55}
56
57/// `activity.requestPermissions(new String[]{permission}, code)`.
58///
59/// The result arrives at `onRequestPermissionsResult`, which the shim does not
60/// implement for these codes — so the request is what makes the *next* call
61/// succeed, and this one still fails. That is the shim's behaviour and the
62/// reason the caller rejects immediately after.
63pub fn request(j: Jni, activity: jobject, permission: [*:0]const u8, code: i32) !void {
64 try j.pushLocalFrame(8);
65 defer _ = j.popLocalFrame(null);
66
67 const string_cls = try j.findClass("java/lang/String");
68 const names = try j.newObjectArray(1, string_cls);
69 try j.setObjectArrayElement(names, 0, try j.newStringUtf(permission));
70
71 const activity_cls = try j.objectClass(activity);
72 try j.callVoidMethodA(
73 activity,
74 try j.methodId(activity_cls, "requestPermissions", "([Ljava/lang/String;I)V"),
75 &.{ .{ .l = names }, .{ .i = code } },
76 );
77}
78
79// =============================================================================
80// Tests
81// =============================================================================
82
83const testing = std.testing;
84
85test "the permission names match Manifest.permission exactly" {
86 // `Manifest.permission.READ_CALENDAR` is a compile-time constant, so the
87 // shim compares against these exact strings. A typo here is a permission
88 // that is never granted and an action that always rejects.
89 try testing.expectEqualStrings("android.permission.READ_CALENDAR", read_calendar);
90 try testing.expectEqualStrings("android.permission.WRITE_CALENDAR", write_calendar);
91}
92
93test "PERMISSION_GRANTED is zero, and PERMISSION_DENIED is not" {
94 // -1 is PERMISSION_DENIED. The comparison is `== granted` rather than
95 // `!= denied` because a future third value would then read as granted.
96 try testing.expectEqual(@as(i32, 0), granted);
97 try testing.expect(granted != -1);
98}
packages/zig/src/bridge_android_calendar.zigmodified+827-13
Changes to packages/zig/src/bridge_android_calendar.zig
@@ -1,5 +1,5 @@
1//! `deleteCalendarEvent` on Android — the first action here that answers
2//! through the reply channel rather than by returning.
1//! The calendar actions on Android, both of which answer through the reply
2//! channel rather than by returning.
33//!
44//! ## What the shim does
55//!
@@ -40,11 +40,21 @@ const jobject = jni.jobject;
4040
4141pub const A = struct {
4242 pub const delete_calendar_event = "deleteCalendarEvent";
43 pub const get_calendar_events = "getCalendarEvents";
44 pub const create_calendar_event = "createCalendarEvent";
4345};
4446
45/// The globals the injected JS assigns for this action.
47/// The globals the injected JS assigns for each action.
48///
49/// `getCalendarEvents` uses `_craftCalendar*` rather than a name of its own,
50/// which is worth noticing rather than tidying: the injected JS assigns them,
51/// and renaming one side is a promise that never settles.
4652pub const resolve_global = "_craftDeleteEventResolve";
4753pub const reject_global = "_craftDeleteEventReject";
54pub const list_resolve_global = "_craftCalendarResolve";
55pub const list_reject_global = "_craftCalendarReject";
56pub const create_resolve_global = "_craftCreateEventResolve";
57pub const create_reject_global = "_craftCreateEventReject";
4858
4959/// `Long.parseLong(text)`, or null where Java would throw.
5060///
@@ -115,30 +125,538 @@ pub fn deleteEvent(j: Jni, activity: jobject, id: i64) !void {
115125/// call being inline — `events.settle` sends the payload as written, so a raw
116126/// message would emit a script that does not parse. See the module comment.
117127pub fn rejectWith(allocator: std.mem.Allocator, message: []const u8) !void {
118 const payload = try rejectPayload(allocator, message);
119 defer allocator.free(payload);
120 try events.settle(allocator, reject_global, payload);
128 try rejectOn(allocator, reject_global, message);
121129}
122130
123/// `message` as a JSON string, quotes included.
131/// `text` as a JSON string, quotes included.
132///
133/// Used for a rejection message and for the id `createCalendarEvent` resolves
134/// with, both of which are page-visible text that has to survive a quote.
124135///
125136/// Split out so the test exercises this rather than a copy of it. An earlier
126/// version inlined the escaping here and asserted on the same three lines
127/// rewritten in the test — which passed happily when the escaping was removed
128/// from the code, because the test was not calling the code.
129pub fn rejectPayload(allocator: std.mem.Allocator, message: []const u8) ![]u8 {
137/// version inlined the escaping at the call site and asserted on the same
138/// three lines rewritten in the test — which passed happily when the escaping
139/// was removed from the code, because the test was not calling the code.
140pub fn jsonString(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
130141 var payload: std.ArrayListUnmanaged(u8) = .empty;
131142 errdefer payload.deinit(allocator);
132143
133144 // The quotes are this caller's job: `appendJsonEscaped` escapes the
134145 // contents and writes no delimiters.
135146 try payload.append(allocator, '"');
136 try bridge_error.appendJsonEscaped(allocator, &payload, message);
147 try bridge_error.appendJsonEscaped(allocator, &payload, text);
137148 try payload.append(allocator, '"');
138149
139150 return payload.toOwnedSlice(allocator);
140151}
141152
153// =============================================================================
154// getCalendarEvents
155// =============================================================================
156//
157// The column names are literals rather than static field reads, and that is
158// the faithful choice rather than the lazy one: `CalendarContract.Events.TITLE`
159// is `public static final String TITLE = "title"`, a compile-time constant, so
160// javac folds it into the shim's DEX. The shim never reads those fields at
161// runtime either. `CONTENT_URI` is a `Uri` object and so is read through JNI,
162// the way `deleteEvent` reads it.
163
164const col_id = "_id";
165const col_title = "title";
166const col_description = "description";
167const col_dtstart = "dtstart";
168const col_dtend = "dtend";
169const col_event_location = "eventLocation";
170const col_all_day = "allDay";
171
172/// The projection, in the order the shim lists it — which is also the order
173/// the cursor indices below depend on.
174const projection = [_][:0]const u8{
175 col_id,
176 col_title,
177 col_description,
178 col_dtstart,
179 col_dtend,
180 col_event_location,
181 col_all_day,
182};
183
184const selection = "(" ++ col_dtstart ++ " >= ?) AND (" ++ col_dtstart ++ " <= ?)";
185const sort_order = col_dtstart ++ " ASC";
186
187/// Thirty days, the shim's default window: `30L * 24 * 60 * 60 * 1000`.
188const thirty_days_ms: i64 = 30 * 24 * 60 * 60 * 1000;
189
190pub const Window = struct { start: i64, end: i64 };
191
192/// The shim's defaulting, including the arithmetic that can wrap.
193///
194/// `startTime + (30L * 24 * 60 * 60 * 1000)` is Kotlin `Long` addition, which
195/// wraps silently rather than throwing — so `+%` is the faithful operator and
196/// `+` would panic in a debug build where the shim quietly returns nothing.
197pub fn windowFor(start_ms: i64, end_ms: i64, now_ms: i64) Window {
198 const start = if (start_ms > 0) start_ms else now_ms;
199 return .{
200 .start = start,
201 // A caller passing an end before the start gets an empty result rather
202 // than an error, because that is what the query does. Not corrected
203 // here: the shim does not correct it either.
204 .end = if (end_ms > 0) end_ms else start +% thirty_days_ms,
205 };
206}
207
208/// One row, as the cursor gives it.
209///
210/// The strings are optional because `Cursor.getString` returns null for a null
211/// column, and the shim treats that differently per field — `?: ""` for five of
212/// them, and nothing at all for the id.
213pub const Event = struct {
214 id: ?[]const u8,
215 title: ?[]const u8,
216 notes: ?[]const u8,
217 start_date: i64,
218 end_date: i64,
219 location: ?[]const u8,
220 all_day: i32,
221};
222
223/// One event as the shim's `JSONObject` would print it.
224///
225/// Two behaviours here are `org.json`'s rather than anything chosen:
226///
227/// - `put(name, null)` **removes** the mapping, so a null id leaves the key
228/// out of the object entirely rather than emitting `"id":null`;
229/// - insertion order is preserved, because `JSONObject` is backed by a
230/// `LinkedHashMap` — so the key order is the order of the shim's `apply`
231/// block and not alphabetical.
232pub fn appendEvent(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), event: Event) !void {
233 try out.append(allocator, '{');
234
235 if (event.id) |id| {
236 try appendStringMember(allocator, out, "id", id);
237 try out.append(allocator, ',');
238 }
239 try appendStringMember(allocator, out, "title", event.title orelse "");
240 try out.append(allocator, ',');
241 try appendStringMember(allocator, out, "notes", event.notes orelse "");
242 try out.append(allocator, ',');
243 try out.appendSlice(allocator, "\"startDate\":");
244 try appendInt(allocator, out, event.start_date);
245 try out.appendSlice(allocator, ",\"endDate\":");
246 try appendInt(allocator, out, event.end_date);
247 try out.append(allocator, ',');
248 try appendStringMember(allocator, out, "location", event.location orelse "");
249
250 // `it.getInt(6) == 1`, so 0 and 2 are both false. Reproduced rather than
251 // relaxed to `!= 0`: `allDay` is only ever 0 or 1, and a bridge that
252 // disagreed with the shim about a third value would disagree silently.
253 try out.appendSlice(allocator, ",\"isAllDay\":");
254 try out.appendSlice(allocator, if (event.all_day == 1) "true" else "false");
255
256 try out.append(allocator, '}');
257}
258
259fn appendStringMember(
260 allocator: std.mem.Allocator,
261 out: *std.ArrayListUnmanaged(u8),
262 name: []const u8,
263 value: []const u8,
264) !void {
265 try out.append(allocator, '"');
266 try out.appendSlice(allocator, name);
267 try out.appendSlice(allocator, "\":\"");
268 try bridge_error.appendJsonEscaped(allocator, out, value);
269 try out.append(allocator, '"');
270}
271
272fn appendInt(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), value: i64) !void {
273 var buf: [32]u8 = undefined;
274 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value}));
275}
276
277/// `Long.toString(value)` as a NUL-terminated string, for `NewStringUTF`.
278///
279/// Decimal with a leading `-` and nothing else, which is what `Long.toString`
280/// produces and what the provider parses back out of a selection argument.
281fn decimalZ(buf: []u8, value: i64) ![*:0]const u8 {
282 const text = try std.fmt.bufPrint(buf[0 .. buf.len - 1], "{d}", .{value});
283 buf[text.len] = 0;
284 return @ptrCast(text.ptr);
285}
286
287/// `System.currentTimeMillis()`.
288///
289/// Through JNI rather than a host clock, because it is what the shim's default
290/// window is measured from — and because `std.time` on this toolchain has no
291/// wall-clock call that works without libc. Belongs in a shared Android helper
292/// the moment a second action needs it.
293pub fn currentTimeMillis(j: Jni) !i64 {
294 try j.pushLocalFrame(4);
295 defer _ = j.popLocalFrame(null);
296
297 const system_cls = try j.findClass("java/lang/System");
298 return j.callStaticLongMethodA(
299 system_cls,
300 try j.staticMethodId(system_cls, "currentTimeMillis", "()J"),
301 &.{},
302 );
303}
304
305/// `contentResolver.query(...)`, every row appended to `out` as JSON.
306///
307/// `out` receives the array including its brackets, so a query returning no
308/// cursor at all produces `[]` — which is what `cursor?.use` does in the shim
309/// when the provider hands back null.
310pub fn queryEvents(
311 j: Jni,
312 allocator: std.mem.Allocator,
313 activity: jobject,
314 window: Window,
315 out: *std.ArrayListUnmanaged(u8),
316) !void {
317 try j.pushLocalFrame(24);
318 defer _ = j.popLocalFrame(null);
319
320 const string_cls = try j.findClass("java/lang/String");
321
322 const projection_array = try j.newObjectArray(projection.len, string_cls);
323 inline for (projection, 0..) |name, i| {
324 try j.setObjectArrayElement(projection_array, i, try j.newStringUtf(name));
325 }
326
327 // `startTime.toString()` — `Long.toString`, which is decimal with a
328 // leading `-` and nothing else, so `bufPrintIntToSlice` matches it.
329 var start_buf: [24]u8 = undefined;
330 var end_buf: [24]u8 = undefined;
331 const args_array = try j.newObjectArray(2, string_cls);
332 try j.setObjectArrayElement(args_array, 0, try j.newStringUtf(try decimalZ(&start_buf, window.start)));
333 try j.setObjectArrayElement(args_array, 1, try j.newStringUtf(try decimalZ(&end_buf, window.end)));
334
335 const events_cls = try j.findClass("android/provider/CalendarContract$Events");
336 const content_uri = try j.staticObjectField(
337 events_cls,
338 try j.staticFieldId(events_cls, "CONTENT_URI", "Landroid/net/Uri;"),
339 );
340
341 const activity_cls = try j.objectClass(activity);
342 const resolver = try j.callObjectMethod(
343 activity,
344 try j.methodId(activity_cls, "getContentResolver", "()Landroid/content/ContentResolver;"),
345 );
346
347 const resolver_cls = try j.objectClass(resolver);
348 const cursor = try j.callObjectMethodA(
349 resolver,
350 try j.methodId(
351 resolver_cls,
352 "query",
353 "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;",
354 ),
355 &.{
356 .{ .l = content_uri },
357 .{ .l = projection_array },
358 .{ .l = try j.newStringUtf(selection) },
359 .{ .l = args_array },
360 .{ .l = try j.newStringUtf(sort_order) },
361 },
362 );
363
364 try out.append(allocator, '[');
365 defer out.append(allocator, ']') catch {};
366
367 // A provider that cannot answer returns null rather than an empty cursor,
368 // and `cursor?.use` skips the loop. An empty array is the honest reply:
369 // the shim resolves with one too.
370 if (cursor == null) return;
371
372 const cursor_cls = try j.objectClass(cursor);
373 const move_to_next = try j.methodId(cursor_cls, "moveToNext", "()Z");
374 const get_string = try j.methodId(cursor_cls, "getString", "(I)Ljava/lang/String;");
375 const get_long = try j.methodId(cursor_cls, "getLong", "(I)J");
376 const get_int = try j.methodId(cursor_cls, "getInt", "(I)I");
377 const close = try j.methodId(cursor_cls, "close", "()V");
378
379 // `use` closes the cursor however the block leaves — including on the way
380 // out of an error, which is what leaks a provider connection otherwise.
381 defer j.callVoidMethodA(cursor, close, &.{}) catch {};
382
383 var count: usize = 0;
384 while (try j.callBooleanMethodA(cursor, move_to_next, &.{})) {
385 // Each row's strings are local references, and the JVM guarantees only
386 // sixteen slots. A frame per row is what keeps a long calendar from
387 // overflowing the table, which aborts the process rather than throwing.
388 try j.pushLocalFrame(8);
389 defer _ = j.popLocalFrame(null);
390
391 const event: Event = .{
392 .id = try columnText(j, allocator, cursor, get_string, 0),
393 .title = try columnText(j, allocator, cursor, get_string, 1),
394 .notes = try columnText(j, allocator, cursor, get_string, 2),
395 .start_date = try j.callLongMethodA(cursor, get_long, &.{.{ .i = 3 }}),
396 .end_date = try j.callLongMethodA(cursor, get_long, &.{.{ .i = 4 }}),
397 .location = try columnText(j, allocator, cursor, get_string, 5),
398 .all_day = try j.callIntMethodA(cursor, get_int, &.{.{ .i = 6 }}),
399 };
400
401 if (count != 0) try out.append(allocator, ',');
402 try appendEvent(allocator, out, event);
403 count += 1;
404 }
405}
406
407/// `cursor.getString(index)`, null included.
408///
409/// The allocation outlives the row's local frame because `stringToUtf8` copies
410/// into `allocator` — the `jstring` itself does not.
411fn columnText(
412 j: Jni,
413 allocator: std.mem.Allocator,
414 cursor: jobject,
415 get_string: jni.jmethodID,
416 index: i32,
417) !?[]const u8 {
418 const value = try j.callObjectMethodA(cursor, get_string, &.{.{ .i = index }});
419 if (value == null) return null;
420 return try j.stringToUtf8(allocator, value);
421}
422
423// =============================================================================
424// createCalendarEvent
425// =============================================================================
426//
427// ## Which payloads this serves, and which it hands back
428//
429// The shim reads the event through `org.json`, whose accessors coerce: a
430// `startDate` sent as the string "1700000000000" is a number to `optLong`, a
431// `title` sent as `1.5` is the string "1.5" to `optString`, and reproducing
432// `Double.toString` exactly is not something to guess at.
433//
434// So the rule here is narrow and stated rather than approximated: Zig serves
435// the shape `NewCalendarEvent` declares — strings for the text, numbers for
436// the dates, a boolean for the flag — plus absent keys and explicit nulls. A
437// payload of any other shape makes `parseNewEvent` return null, the native
438// returns false, and the shim reads it with the coercions it already has. The
439// page sees no difference; nothing has been settled at that point.
440//
441// The one coercion that *is* reproduced is the surprising one. `optString` on
442// an explicit JSON null returns the four characters "null", because
443// `JSONObject.NULL.toString()` is "null" and `JSON.toString` reaches for
444// `toString` on anything that is not already a String. `{"location":null}` is
445// an ordinary thing for `JSON.stringify` to produce, so this is a live path
446// and not a curiosity.
447
448/// `CalendarContract.Events.CALENDAR_ID`, `EVENT_TIMEZONE` — the two columns
449/// the shim fills without being asked.
450const col_calendar_id = "calendar_id";
451const col_event_timezone = "eventTimezone";
452
453/// The shim writes calendar 1 unconditionally.
454///
455/// Not the primary calendar, not the first visible one — the row whose `_id`
456/// is 1, whatever that happens to be on the device. Reproduced because it is
457/// what the shim does, and because an event landing in a different calendar
458/// than before would be a behaviour change nobody asked for.
459const default_calendar_id: i32 = 1;
460
461/// The event to insert, in the shape `NewCalendarEvent` declares.
462pub const NewEvent = struct {
463 title: []const u8,
464 notes: []const u8,
465 location: []const u8,
466 start_date: i64,
467 end_date: i64,
468 all_day: bool,
469};
470
471/// Read the declared shape, or null where only `org.json`'s coercions would.
472///
473/// `default_start` and `default_end` are the shim's two separate
474/// `System.currentTimeMillis()` calls — separate because Kotlin evaluates each
475/// argument as its `put` runs, so the default end really can be a millisecond
476/// more than `start + 3600000`.
477pub fn parseNewEvent(value: std.json.Value, default_start: i64, default_end: i64) ?NewEvent {
478 const object = switch (value) {
479 .object => |o| o,
480 else => return null,
481 };
482
483 return .{
484 .title = optString(object, "title") orelse return null,
485 .notes = optString(object, "notes") orelse return null,
486 .location = optString(object, "location") orelse return null,
487 .start_date = optLong(object, "startDate", default_start) orelse return null,
488 .end_date = optLong(object, "endDate", default_end) orelse return null,
489 .all_day = optBoolean(object, "isAllDay") orelse return null,
490 };
491}
492
493/// `event.optString(name, "")`, for the shapes this serves.
494///
495/// Outer null means "a shape the shim would coerce and this will not".
496fn optString(object: std.json.ObjectMap, name: []const u8) ?[]const u8 {
497 const value = object.get(name) orelse return "";
498 return switch (value) {
499 .string => |text| text,
500 // `JSONObject.NULL.toString()`. Not a typo and not a fallback: the
501 // shim really does store the four characters.
502 .null => "null",
503 else => null,
504 };
505}
506
507/// `event.optLong(name, fallback)`, for the shapes this serves.
508fn optLong(object: std.json.ObjectMap, name: []const u8, fallback: i64) ?i64 {
509 const value = object.get(name) orelse return fallback;
510 return switch (value) {
511 .integer => |n| n,
512 // `JSON.toLong(NULL)` is null, so the fallback wins — unlike
513 // `optString`, where the same null becomes text.
514 .null => fallback,
515 else => null,
516 };
517}
518
519/// `event.optBoolean(name, false)`, for the shapes this serves.
520fn optBoolean(object: std.json.ObjectMap, name: []const u8) ?bool {
521 const value = object.get(name) orelse return false;
522 return switch (value) {
523 .bool => |b| b,
524 .null => false,
525 else => null,
526 };
527}
528
529/// `contentResolver.insert(Events.CONTENT_URI, values)`, and the id it lands at.
530///
531/// Returns `uri?.lastPathSegment ?: ""` — so a provider that refuses the insert
532/// resolves the page's promise with an empty string rather than rejecting,
533/// which is the shim's answer and not an improvement on it.
534pub fn insertEvent(j: Jni, allocator: std.mem.Allocator, activity: jobject, event: NewEvent) ![]u8 {
535 try j.pushLocalFrame(32);
536 defer _ = j.popLocalFrame(null);
537
538 const values_cls = try j.findClass("android/content/ContentValues");
539 const values = try j.newObjectA(values_cls, try j.methodId(values_cls, "<init>", "()V"), &.{});
540
541 // `ContentValues.put` is overloaded on the *boxed* types, so each number
542 // has to be boxed before it can be put.
543 const put_string = try j.methodId(values_cls, "put", "(Ljava/lang/String;Ljava/lang/String;)V");
544 const put_long = try j.methodId(values_cls, "put", "(Ljava/lang/String;Ljava/lang/Long;)V");
545 const put_int = try j.methodId(values_cls, "put", "(Ljava/lang/String;Ljava/lang/Integer;)V");
546
547 try putString(j, allocator, values, put_string, col_title, event.title);
548 try putString(j, allocator, values, put_string, col_description, event.notes);
549 try putString(j, allocator, values, put_string, col_event_location, event.location);
550 try putLong(j, values, put_long, col_dtstart, event.start_date);
551 try putLong(j, values, put_long, col_dtend, event.end_date);
552 try putInt(j, values, put_int, col_all_day, if (event.all_day) 1 else 0);
553 try putInt(j, values, put_int, col_calendar_id, default_calendar_id);
554
555 // `TimeZone.getDefault().id`. The provider needs one, and an event
556 // inserted without it reads back at the wrong hour.
557 const timezone_cls = try j.findClass("java/util/TimeZone");
558 const timezone = try j.callStaticObjectMethodA(
559 timezone_cls,
560 try j.staticMethodId(timezone_cls, "getDefault", "()Ljava/util/TimeZone;"),
561 &.{},
562 );
563 const timezone_id = try j.callObjectMethod(
564 timezone,
565 try j.methodId(try j.objectClass(timezone), "getID", "()Ljava/lang/String;"),
566 );
567 try j.callVoidMethodA(values, put_string, &.{
568 .{ .l = try j.newStringUtf(col_event_timezone) },
569 .{ .l = timezone_id },
570 });
571
572 const events_cls = try j.findClass("android/provider/CalendarContract$Events");
573 const content_uri = try j.staticObjectField(
574 events_cls,
575 try j.staticFieldId(events_cls, "CONTENT_URI", "Landroid/net/Uri;"),
576 );
577
578 const activity_cls = try j.objectClass(activity);
579 const resolver = try j.callObjectMethod(
580 activity,
581 try j.methodId(activity_cls, "getContentResolver", "()Landroid/content/ContentResolver;"),
582 );
583
584 const uri = try j.callObjectMethodA(
585 resolver,
586 try j.methodId(
587 try j.objectClass(resolver),
588 "insert",
589 "(Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri;",
590 ),
591 &.{ .{ .l = content_uri }, .{ .l = values } },
592 );
593
594 if (uri == null) return allocator.dupe(u8, "");
595
596 const segment = try j.callObjectMethod(
597 uri,
598 try j.methodId(try j.objectClass(uri), "getLastPathSegment", "()Ljava/lang/String;"),
599 );
600 if (segment == null) return allocator.dupe(u8, "");
601 return j.stringToUtf8(allocator, segment);
602}
603
604fn putString(
605 j: Jni,
606 allocator: std.mem.Allocator,
607 values: jobject,
608 put: jni.jmethodID,
609 column: [:0]const u8,
610 text: []const u8,
611) !void {
612 try j.pushLocalFrame(4);
613 defer _ = j.popLocalFrame(null);
614
615 // `NewStringUTF` needs a NUL terminator, and a title is arbitrary page
616 // text — so it is copied rather than pointed at.
617 const terminated = try allocator.allocSentinel(u8, text.len, 0);
618 defer allocator.free(terminated);
619 @memcpy(terminated, text);
620
621 try j.callVoidMethodA(values, put, &.{
622 .{ .l = try j.newStringUtf(column) },
623 .{ .l = try j.newStringUtf(terminated.ptr) },
624 });
625}
626
627fn putLong(j: Jni, values: jobject, put: jni.jmethodID, column: [:0]const u8, value: i64) !void {
628 try j.pushLocalFrame(4);
629 defer _ = j.popLocalFrame(null);
630
631 const long_cls = try j.findClass("java/lang/Long");
632 const boxed = try j.callStaticObjectMethodA(
633 long_cls,
634 try j.staticMethodId(long_cls, "valueOf", "(J)Ljava/lang/Long;"),
635 &.{.{ .j = value }},
636 );
637 try j.callVoidMethodA(values, put, &.{ .{ .l = try j.newStringUtf(column) }, .{ .l = boxed } });
638}
639
640fn putInt(j: Jni, values: jobject, put: jni.jmethodID, column: [:0]const u8, value: i32) !void {
641 try j.pushLocalFrame(4);
642 defer _ = j.popLocalFrame(null);
643
644 const integer_cls = try j.findClass("java/lang/Integer");
645 const boxed = try j.callStaticObjectMethodA(
646 integer_cls,
647 try j.staticMethodId(integer_cls, "valueOf", "(I)Ljava/lang/Integer;"),
648 &.{.{ .i = value }},
649 );
650 try j.callVoidMethodA(values, put, &.{ .{ .l = try j.newStringUtf(column) }, .{ .l = boxed } });
651}
652
653/// `message` as a JSON string, for whichever reject global the caller owns.
654pub fn rejectOn(allocator: std.mem.Allocator, global: []const u8, message: []const u8) !void {
655 const payload = try jsonString(allocator, message);
656 defer allocator.free(payload);
657 try events.settle(allocator, global, payload);
658}
659
142660// =============================================================================
143661// Tests
144662// =============================================================================
@@ -173,7 +691,7 @@ test "a rejection message with a quote is escaped rather than emitted raw" {
173691 // The bug that used to be one line away. Before #154 the shim turned an id
174692 // of `1'x` into `…Reject('For input string: "1'x"')`, which does not parse
175693 // — so evaluateJavascript ran nothing and the promise never settled.
176 const payload = try rejectPayload(testing.allocator, "For input string: \"1'x\"");
694 const payload = try jsonString(testing.allocator, "For input string: \"1'x\"");
177695 defer testing.allocator.free(payload);
178696
179697 // Valid JSON, and it round-trips to exactly the original message.
@@ -193,3 +711,299 @@ test "the action name and its globals match the shim exactly" {
193711 try testing.expectEqualStrings("_craftDeleteEventResolve", resolve_global);
194712 try testing.expectEqualStrings("_craftDeleteEventReject", reject_global);
195713}
714
715test "an absent window falls back the way the shim's does" {
716 const now: i64 = 1_700_000_000_000;
717
718 // Both supplied: used as given, including an end before the start. The
719 // shim does not correct that and neither does this.
720 try testing.expectEqual(Window{ .start = 10, .end = 20 }, windowFor(10, 20, now));
721 try testing.expectEqual(Window{ .start = 30, .end = 20 }, windowFor(30, 20, now));
722
723 // `if (startDateMs > 0)` — zero and negative both mean "now", which is why
724 // this is not `>= 0`.
725 try testing.expectEqual(now, windowFor(0, 20, now).start);
726 try testing.expectEqual(now, windowFor(-1, 20, now).start);
727
728 // Thirty days, in milliseconds.
729 try testing.expectEqual(now + 2_592_000_000, windowFor(0, 0, now).end);
730 try testing.expectEqual(@as(i64, 10 + 2_592_000_000), windowFor(10, 0, now).end);
731}
732
733test "a window whose default end overflows wraps rather than trapping" {
734 // Kotlin `Long` addition wraps. A page passing a start near Long.MAX_VALUE
735 // gets a nonsensical window and an empty result from the shim; a `+` here
736 // would instead panic in a debug build, which is a different bug.
737 const near_max: i64 = std.math.maxInt(i64) - 5;
738 try testing.expectEqual(near_max +% 2_592_000_000, windowFor(near_max, 0, 0).end);
739}
740
741fn renderEvents(events_in: []const Event) ![]u8 {
742 var out: std.ArrayListUnmanaged(u8) = .empty;
743 errdefer out.deinit(testing.allocator);
744 try out.append(testing.allocator, '[');
745 for (events_in, 0..) |event, i| {
746 if (i != 0) try out.append(testing.allocator, ',');
747 try appendEvent(testing.allocator, &out, event);
748 }
749 try out.append(testing.allocator, ']');
750 return out.toOwnedSlice(testing.allocator);
751}
752
753const sample: Event = .{
754 .id = "17",
755 .title = "Standup",
756 .notes = "",
757 .start_date = 1_700_000_000_000,
758 .end_date = 1_700_000_900_000,
759 .location = "Room 2",
760 .all_day = 0,
761};
762
763test "an event prints the keys the shim's JSONObject prints, in that order" {
764 const json = try renderEvents(&.{sample});
765 defer testing.allocator.free(json);
766
767 // JSONObject is a LinkedHashMap, so the shim emits insertion order — the
768 // order of its `apply` block. Asserted as a whole string rather than key
769 // by key, because the order is the part that a rewrite would lose.
770 try testing.expectEqualStrings(
771 \\[{"id":"17","title":"Standup","notes":"","startDate":1700000000000,"endDate":1700000900000,"location":"Room 2","isAllDay":false}]
772 , json);
773}
774
775test "a null id leaves the key out, and null text becomes empty" {
776 // `put(name, null)` removes the mapping rather than storing JSON null, so
777 // the shim's object has no `id` at all. The other five columns are read
778 // with `?: ""`, which is a different answer to the same null.
779 const json = try renderEvents(&.{.{
780 .id = null,
781 .title = null,
782 .notes = null,
783 .start_date = 0,
784 .end_date = 0,
785 .location = null,
786 .all_day = 1,
787 }});
788 defer testing.allocator.free(json);
789
790 try testing.expectEqualStrings(
791 \\[{"title":"","notes":"","startDate":0,"endDate":0,"location":"","isAllDay":true}]
792 , json);
793}
794
795test "isAllDay is the shim's == 1 and not a truthiness test" {
796 for ([_]struct { value: i32, expected: []const u8 }{
797 .{ .value = 0, .expected = "\"isAllDay\":false" },
798 .{ .value = 1, .expected = "\"isAllDay\":true" },
799 // `getInt(6) == 1` says false here, and a `!= 0` would say true. The
800 // column only ever holds 0 or 1, so this row is the one that would go
801 // unnoticed — which is why it is written down.
802 .{ .value = 2, .expected = "\"isAllDay\":false" },
803 .{ .value = -1, .expected = "\"isAllDay\":false" },
804 }) |case| {
805 var event = sample;
806 event.all_day = case.value;
807 const json = try renderEvents(&.{event});
808 defer testing.allocator.free(json);
809
810 try testing.expect(std.mem.indexOf(u8, json, case.expected) != null);
811 }
812}
813
814test "a title carrying a quote survives as JSON rather than breaking the reply" {
815 // The payload goes to `events.settle`, which sends it as written. An event
816 // called `Bob's "1:1"` is ordinary, and it is exactly what an unescaped
817 // build would turn into a script that does not parse.
818 var event = sample;
819 event.title = "Bob's \"1:1\"";
820 event.location = "line\nbreak";
821
822 const json = try renderEvents(&.{event});
823 defer testing.allocator.free(json);
824
825 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
826 defer parsed.deinit();
827
828 const first = parsed.value.array.items[0].object;
829 try testing.expectEqualStrings("Bob's \"1:1\"", first.get("title").?.string);
830 try testing.expectEqualStrings("line\nbreak", first.get("location").?.string);
831 try testing.expect(first.get("id") != null);
832}
833
834test "an empty calendar and a full one both parse" {
835 const empty = try renderEvents(&.{});
836 defer testing.allocator.free(empty);
837 try testing.expectEqualStrings("[]", empty);
838
839 var second = sample;
840 second.id = "18";
841 const two = try renderEvents(&.{ sample, second });
842 defer testing.allocator.free(two);
843
844 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, two, .{});
845 defer parsed.deinit();
846 try testing.expectEqual(@as(usize, 2), parsed.value.array.items.len);
847}
848
849test "the query strings are the shim's, character for character" {
850 // The selection and sort order are built from the same column constants
851 // the projection uses, so a typo would have to be in one place. Asserted
852 // against literals anyway: these are what the provider parses, and the
853 // constants they are built from are not independently checked.
854 try testing.expectEqualStrings("(dtstart >= ?) AND (dtstart <= ?)", selection);
855 try testing.expectEqualStrings("dtstart ASC", sort_order);
856 try testing.expectEqual(@as(usize, 7), projection.len);
857 try testing.expectEqualStrings("_id", projection[0]);
858 try testing.expectEqualStrings("eventLocation", projection[5]);
859 try testing.expectEqualStrings("allDay", projection[6]);
860}
861
862test "getCalendarEvents names its action and globals as the shim does" {
863 try testing.expectEqualStrings("getCalendarEvents", A.get_calendar_events);
864 try testing.expectEqualStrings("_craftCalendarResolve", list_resolve_global);
865 try testing.expectEqualStrings("_craftCalendarReject", list_reject_global);
866}
867
868// --- createCalendarEvent ---------------------------------------------------
869
870fn parseEvent(json: []const u8, default_start: i64, default_end: i64) !?NewEvent {
871 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
872 defer parsed.deinit();
873
874 // The strings borrow from `parsed`, so anything a caller keeps has to be
875 // read before this returns. Every test below asserts inside its own call.
876 const event = parseNewEvent(parsed.value, default_start, default_end) orelse return null;
877 return NewEvent{
878 .title = try testing.allocator.dupe(u8, event.title),
879 .notes = try testing.allocator.dupe(u8, event.notes),
880 .location = try testing.allocator.dupe(u8, event.location),
881 .start_date = event.start_date,
882 .end_date = event.end_date,
883 .all_day = event.all_day,
884 };
885}
886
887fn freeEvent(event: NewEvent) void {
888 testing.allocator.free(event.title);
889 testing.allocator.free(event.notes);
890 testing.allocator.free(event.location);
891}
892
893test "the declared shape reads straight through" {
894 const event = (try parseEvent(
895 \\{"title":"Standup","notes":"daily","location":"Room 2",
896 \\ "startDate":1700000000000,"endDate":1700000900000,"isAllDay":false}
897 , 1, 2)).?;
898 defer freeEvent(event);
899
900 try testing.expectEqualStrings("Standup", event.title);
901 try testing.expectEqualStrings("daily", event.notes);
902 try testing.expectEqualStrings("Room 2", event.location);
903 try testing.expectEqual(@as(i64, 1700000000000), event.start_date);
904 try testing.expectEqual(@as(i64, 1700000900000), event.end_date);
905 try testing.expect(!event.all_day);
906}
907
908test "absent keys take the shim's defaults" {
909 // `optString(name, "")`, `optLong(name, now)`, `optBoolean(name, false)`.
910 // The two date defaults are separate arguments because the shim calls
911 // System.currentTimeMillis() twice, once per put.
912 const event = (try parseEvent("{}", 111, 222)).?;
913 defer freeEvent(event);
914
915 try testing.expectEqualStrings("", event.title);
916 try testing.expectEqualStrings("", event.notes);
917 try testing.expectEqualStrings("", event.location);
918 try testing.expectEqual(@as(i64, 111), event.start_date);
919 try testing.expectEqual(@as(i64, 222), event.end_date);
920 try testing.expect(!event.all_day);
921}
922
923test "an explicit null is text to optString and a fallback to optLong" {
924 // The org.json quirk, and the reason it is worth a test rather than a
925 // comment: `JSONObject.NULL.toString()` is "null", so `optString` hands
926 // back four characters where every other accessor hands back the default.
927 // `JSON.stringify({location: null})` produces this every day.
928 const event = (try parseEvent(
929 \\{"title":null,"notes":null,"location":null,"startDate":null,"endDate":null,"isAllDay":null}
930 , 111, 222)).?;
931 defer freeEvent(event);
932
933 try testing.expectEqualStrings("null", event.title);
934 try testing.expectEqualStrings("null", event.notes);
935 try testing.expectEqualStrings("null", event.location);
936 try testing.expectEqual(@as(i64, 111), event.start_date);
937 try testing.expectEqual(@as(i64, 222), event.end_date);
938 try testing.expect(!event.all_day);
939}
940
941test "a shape only org.json would coerce is handed back rather than guessed at" {
942 // Each of these is something `optString`/`optLong`/`optBoolean` reads
943 // happily and this does not. Returning null sends the native down its
944 // false path, the shim reads it, and the page cannot tell — which is the
945 // whole reason the rule can be this narrow.
946 for ([_][]const u8{
947 // A number where NewCalendarEvent declares a string: optString would
948 // give "1.5", and reproducing Double.toString is not a guess to make.
949 \\{"title":1.5}
950 ,
951 \\{"title":7}
952 ,
953 \\{"location":true}
954 ,
955 // A string where it declares a number: optLong parses it as a double
956 // and truncates, so "1.9" is 1.
957 \\{"startDate":"1700000000000"}
958 ,
959 \\{"endDate":"1.9"}
960 ,
961 // A float where it declares a number: optLong truncates toward zero.
962 \\{"startDate":1.9}
963 ,
964 // optBoolean reads "true" and "false" case-insensitively.
965 \\{"isAllDay":"true"}
966 ,
967 \\{"isAllDay":1}
968 ,
969 // Not an object at all.
970 \\[]
971 ,
972 \\"title"
973 ,
974 }) |payload| {
975 const event = try parseEvent(payload, 1, 2);
976 if (event) |kept| {
977 freeEvent(kept);
978 std.debug.print("payload was served rather than handed back: {s}\n", .{payload});
979 return error.CoercedShapeAccepted;
980 }
981 }
982}
983
984test "the columns createCalendarEvent fills are the shim's" {
985 try testing.expectEqualStrings("calendar_id", col_calendar_id);
986 try testing.expectEqualStrings("eventTimezone", col_event_timezone);
987
988 // The shim writes calendar 1 unconditionally — not the primary calendar,
989 // the row whose _id is 1. Written down because it looks like a bug and is
990 // instead a faithful port of one.
991 try testing.expectEqual(@as(i32, 1), default_calendar_id);
992}
993
994test "createCalendarEvent names its action and globals as the shim does" {
995 try testing.expectEqualStrings("createCalendarEvent", A.create_calendar_event);
996 try testing.expectEqualStrings("_craftCreateEventResolve", create_resolve_global);
997 try testing.expectEqualStrings("_craftCreateEventReject", create_reject_global);
998}
999
1000test "an id carrying a quote resolves as JSON rather than breaking the reply" {
1001 // `uri.lastPathSegment` is provider-controlled text, and it goes to the
1002 // page through the same channel a rejection does.
1003 const payload = try jsonString(testing.allocator, "17'x");
1004 defer testing.allocator.free(payload);
1005
1006 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
1007 defer parsed.deinit();
1008 try testing.expectEqualStrings("17'x", parsed.value.string);
1009}
packages/zig/src/jni_runtime.zigmodified+47-4
Changes to packages/zig/src/jni_runtime.zig
@@ -642,6 +642,14 @@ pub const Jni = struct {
642642 return result != JNI_FALSE;
643643 }
644644
645 pub fn callLongMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!jlong {
646 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) jlong =
647 @ptrCast(self.table().CallLongMethodA orelse return JniError.NotFound);
648 const result = call(self.env, obj, id, args.ptr);
649 try self.check();
650 return result;
651 }
652
645653 pub fn callVoidMethodA(self: Self, obj: jobject, id: jmethodID, args: []const jvalue) JniError!void {
646654 const call: *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) void =
647655 @ptrCast(self.table().CallVoidMethodA orelse return JniError.NotFound);
@@ -706,10 +714,14 @@ pub const Jni = struct {
706714
707715 // --- Arrays -----------------------------------------------------------
708716 //
709 // Only `long[]` so far, because only `VibrationEffect.createWaveform`
710 // needs one. `SetLongArrayRegion` copies from a Zig slice in one call
711 // rather than element by element, which is both faster and the only form
712 // that cannot leave a partially-filled array behind on an error.
717 // `long[]` for `VibrationEffect.createWaveform`, and `String[]` for the
718 // projection and selection arguments a `ContentResolver.query` takes.
719 //
720 // The two are shaped differently because JNI is: a primitive array can be
721 // filled from a Zig slice in one `SetLongArrayRegion` call, which is both
722 // faster and the only form that cannot leave a partially-filled array
723 // behind on an error. An object array has no region form and has to be
724 // written a slot at a time.
713725
714726 pub fn newLongArray(self: Self, values: []const jlong) JniError!jobject {
715727 const new: *const fn (JNIEnv, jsize) callconv(.c) jobject =
@@ -728,6 +740,29 @@ pub const Jni = struct {
728740 return array;
729741 }
730742
743 /// `new <element_class>[len]`, every slot null.
744 ///
745 /// `NewObjectArray` takes an initial element for every slot; null is what
746 /// the Kotlin's `arrayOf(...)` starts from before its entries are written,
747 /// and every caller here fills the array immediately.
748 pub fn newObjectArray(self: Self, len: usize, element_class: jclass) JniError!jobject {
749 const new: *const fn (JNIEnv, jsize, jclass, jobject) callconv(.c) jobject =
750 @ptrCast(self.table().NewObjectArray orelse return JniError.NotFound);
751 const array = new(self.env, @intCast(len), element_class, null) orelse {
752 try self.check();
753 return JniError.OutOfMemory;
754 };
755 try self.check();
756 return array;
757 }
758
759 pub fn setObjectArrayElement(self: Self, array: jobject, index: usize, value: jobject) JniError!void {
760 const set: *const fn (JNIEnv, jobject, jsize, jobject) callconv(.c) void =
761 @ptrCast(self.table().SetObjectArrayElement orelse return JniError.NotFound);
762 set(self.env, array, @intCast(index), value);
763 try self.check();
764 }
765
731766 /// Make a Java `String` from UTF-8.
732767 ///
733768 /// The JVM wants *modified* UTF-8 here, the same encoding `stringToUtf8`
@@ -783,6 +818,14 @@ pub const Jni = struct {
783818 return result;
784819 }
785820
821 pub fn callStaticLongMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!jlong {
822 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jlong =
823 @ptrCast(self.table().CallStaticLongMethodA orelse return JniError.NotFound);
824 const result = call(self.env, cls, id, args.ptr);
825 try self.check();
826 return result;
827 }
828
786829 pub fn callStaticVoidMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!void {
787830 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) void =
788831 @ptrCast(self.table().CallStaticVoidMethodA orelse return JniError.NotFound);
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -63,8 +63,10 @@ const zig_sources = [_][]const u8{
6363/// 91 with the secure-storage quartet, the first to take a Kotlin-held object
6464/// rather than the Activity; 89 with haptic and vibrate; 87 with the
6565/// 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;
66/// through the reply channel rather than by returning; 85 with
67/// getCalendarEvents, the first to send the page a payload it built rather
68/// than a constant; 84 with createCalendarEvent, the first to read one.
69const max_not_yet_migrated: usize = 84;
6870
6971/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
7072///