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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/templates/CraftBridge.kt.templatemodified+4-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)
packages/android/templates/CraftNative.kt.templatemodified+17-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -108,6 +108,7 @@ 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
111112
112113 /**
113114 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -350,4 +351,20 @@ object CraftNative {
350351 false
351352 }
352353 }
354
355 /**
356 * Returns whether Zig took the action, not whether the read found anything.
357 *
358 * An empty calendar still answers — with `[]`, through the reply channel —
359 * so a false here means Zig did not run, never that there was nothing to
360 * report.
361 */
362 fun getCalendarEvents(activity: Activity, startDateMs: Long, endDateMs: Long): Boolean {
363 if (!isAvailable) return false
364 return try {
365 nativeGetCalendarEvents(activity, startDateMs, endDateMs)
366 } catch (e: UnsatisfiedLinkError) {
367 false
368 }
369 }
353370}
packages/zig/src/android_dispatch.zigmodified+78-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,14 @@ 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
6675/// The VM, captured at load so a later callback on a framework thread can ask
6776/// it for that thread's `JNIEnv`.
6877///
@@ -226,6 +235,11 @@ const natives = [_]jni.JNINativeMethod{
226235 .signature = "(Landroid/app/Activity;Ljava/lang/String;)Z",
227236 .fnPtr = @ptrCast(&nativeDeleteCalendarEvent),
228237 },
238 .{
239 .name = "nativeGetCalendarEvents",
240 .signature = "(Landroid/app/Activity;JJ)Z",
241 .fnPtr = @ptrCast(&nativeGetCalendarEvents),
242 },
229243};
230244
231245/// How binding went. A value rather than a log line, so every path is
@@ -642,6 +656,69 @@ fn nativeDeleteCalendarEvent(
642656 return jni.JNI_TRUE;
643657}
644658
659/// `nativeGetCalendarEvents(activity, startDateMs, endDateMs)`.
660///
661/// Reads the calendar and settles `_craftCalendarResolve` with the array.
662///
663/// ## Where this returns false
664///
665/// Only before anything has been sent. A failure after the reply channel has
666/// been used would settle the page's promise twice, since a false sends the
667/// shim down the same path — so the JNI work runs to completion or reports
668/// nothing at all, and the shim answers instead.
669///
670/// That includes the case where the query itself throws. The shim wraps none
671/// of `getCalendarEvents` in a try/catch, so a `SecurityException` from a
672/// permission revoked between the check and the query propagates out of the
673/// `@JavascriptInterface` method and the promise hangs. Rejecting here would
674/// be kinder and would also be a divergence nothing records, so this falls
675/// through and lets the shim behave as it does. See #157.
676fn nativeGetCalendarEvents(
677 env: jni.JNIEnv,
678 _: jni.jobject,
679 activity: jni.jobject,
680 start_date_ms: jni.jlong,
681 end_date_ms: jni.jlong,
682) callconv(.c) jni.jboolean {
683 const j = Jni.init(env);
684 var arena = std.heap.ArenaAllocator.init(backing);
685 defer arena.deinit();
686 const allocator = arena.allocator();
687
688 const granted = permissions.isGranted(j, activity, permissions.read_calendar) catch |err| {
689 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
690 return jni.JNI_FALSE;
691 };
692
693 if (!granted) {
694 // The shim asks and rejects in the same breath: the answer arrives at
695 // `onRequestPermissionsResult`, which nothing here implements, so the
696 // request is what makes a *later* call work rather than this one.
697 permissions.request(j, activity, permissions.read_calendar, request_calendar) catch |err| {
698 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
699 return jni.JNI_FALSE;
700 };
701 events.settle(allocator, calendar.list_reject_global, "\"Permission denied\"") catch return jni.JNI_FALSE;
702 return jni.JNI_TRUE;
703 }
704
705 const now = calendar.currentTimeMillis(j) catch |err| {
706 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
707 return jni.JNI_FALSE;
708 };
709 const window = calendar.windowFor(start_date_ms, end_date_ms, now);
710
711 var payload: std.ArrayListUnmanaged(u8) = .empty;
712 defer payload.deinit(allocator);
713 calendar.queryEvents(j, allocator, activity, window, &payload) catch |err| {
714 std.log.warn("craft: getCalendarEvents fell through to the shim ({s})", .{@errorName(err)});
715 return jni.JNI_FALSE;
716 };
717
718 events.settle(allocator, calendar.list_resolve_global, payload.items) catch return jni.JNI_FALSE;
719 return jni.JNI_TRUE;
720}
721
645722// =============================================================================
646723// Tests
647724// =============================================================================
@@ -753,7 +830,7 @@ test "the registered natives name methods the Kotlin actually declares" {
753830 // A descriptor is checked by the JVM at registration, so a wrong one fails
754831 // at load rather than at call — but only if the *name* matches something.
755832 // These two strings are the contract with CraftBridge.kt.
756 try testing.expectEqual(@as(usize, 17), natives.len);
833 try testing.expectEqual(@as(usize, 18), natives.len);
757834 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
758835
759836 // 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+433-3
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,18 @@ const jobject = jni.jobject;
4040
4141pub const A = struct {
4242 pub const delete_calendar_event = "deleteCalendarEvent";
43 pub const get_calendar_events = "getCalendarEvents";
4344};
4445
45/// The globals the injected JS assigns for this action.
46/// The globals the injected JS assigns for each action.
47///
48/// `getCalendarEvents` uses `_craftCalendar*` rather than a name of its own,
49/// which is worth noticing rather than tidying: the injected JS assigns them,
50/// and renaming one side is a promise that never settles.
4651pub const resolve_global = "_craftDeleteEventResolve";
4752pub const reject_global = "_craftDeleteEventReject";
53pub const list_resolve_global = "_craftCalendarResolve";
54pub const list_reject_global = "_craftCalendarReject";
4855
4956/// `Long.parseLong(text)`, or null where Java would throw.
5057///
@@ -139,6 +146,276 @@ pub fn rejectPayload(allocator: std.mem.Allocator, message: []const u8) ![]u8 {
139146 return payload.toOwnedSlice(allocator);
140147}
141148
149// =============================================================================
150// getCalendarEvents
151// =============================================================================
152//
153// The column names are literals rather than static field reads, and that is
154// the faithful choice rather than the lazy one: `CalendarContract.Events.TITLE`
155// is `public static final String TITLE = "title"`, a compile-time constant, so
156// javac folds it into the shim's DEX. The shim never reads those fields at
157// runtime either. `CONTENT_URI` is a `Uri` object and so is read through JNI,
158// the way `deleteEvent` reads it.
159
160const col_id = "_id";
161const col_title = "title";
162const col_description = "description";
163const col_dtstart = "dtstart";
164const col_dtend = "dtend";
165const col_event_location = "eventLocation";
166const col_all_day = "allDay";
167
168/// The projection, in the order the shim lists it — which is also the order
169/// the cursor indices below depend on.
170const projection = [_][:0]const u8{
171 col_id,
172 col_title,
173 col_description,
174 col_dtstart,
175 col_dtend,
176 col_event_location,
177 col_all_day,
178};
179
180const selection = "(" ++ col_dtstart ++ " >= ?) AND (" ++ col_dtstart ++ " <= ?)";
181const sort_order = col_dtstart ++ " ASC";
182
183/// Thirty days, the shim's default window: `30L * 24 * 60 * 60 * 1000`.
184const thirty_days_ms: i64 = 30 * 24 * 60 * 60 * 1000;
185
186pub const Window = struct { start: i64, end: i64 };
187
188/// The shim's defaulting, including the arithmetic that can wrap.
189///
190/// `startTime + (30L * 24 * 60 * 60 * 1000)` is Kotlin `Long` addition, which
191/// wraps silently rather than throwing — so `+%` is the faithful operator and
192/// `+` would panic in a debug build where the shim quietly returns nothing.
193pub fn windowFor(start_ms: i64, end_ms: i64, now_ms: i64) Window {
194 const start = if (start_ms > 0) start_ms else now_ms;
195 return .{
196 .start = start,
197 // A caller passing an end before the start gets an empty result rather
198 // than an error, because that is what the query does. Not corrected
199 // here: the shim does not correct it either.
200 .end = if (end_ms > 0) end_ms else start +% thirty_days_ms,
201 };
202}
203
204/// One row, as the cursor gives it.
205///
206/// The strings are optional because `Cursor.getString` returns null for a null
207/// column, and the shim treats that differently per field — `?: ""` for five of
208/// them, and nothing at all for the id.
209pub const Event = struct {
210 id: ?[]const u8,
211 title: ?[]const u8,
212 notes: ?[]const u8,
213 start_date: i64,
214 end_date: i64,
215 location: ?[]const u8,
216 all_day: i32,
217};
218
219/// One event as the shim's `JSONObject` would print it.
220///
221/// Two behaviours here are `org.json`'s rather than anything chosen:
222///
223/// - `put(name, null)` **removes** the mapping, so a null id leaves the key
224/// out of the object entirely rather than emitting `"id":null`;
225/// - insertion order is preserved, because `JSONObject` is backed by a
226/// `LinkedHashMap` — so the key order is the order of the shim's `apply`
227/// block and not alphabetical.
228pub fn appendEvent(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), event: Event) !void {
229 try out.append(allocator, '{');
230
231 if (event.id) |id| {
232 try appendStringMember(allocator, out, "id", id);
233 try out.append(allocator, ',');
234 }
235 try appendStringMember(allocator, out, "title", event.title orelse "");
236 try out.append(allocator, ',');
237 try appendStringMember(allocator, out, "notes", event.notes orelse "");
238 try out.append(allocator, ',');
239 try out.appendSlice(allocator, "\"startDate\":");
240 try appendInt(allocator, out, event.start_date);
241 try out.appendSlice(allocator, ",\"endDate\":");
242 try appendInt(allocator, out, event.end_date);
243 try out.append(allocator, ',');
244 try appendStringMember(allocator, out, "location", event.location orelse "");
245
246 // `it.getInt(6) == 1`, so 0 and 2 are both false. Reproduced rather than
247 // relaxed to `!= 0`: `allDay` is only ever 0 or 1, and a bridge that
248 // disagreed with the shim about a third value would disagree silently.
249 try out.appendSlice(allocator, ",\"isAllDay\":");
250 try out.appendSlice(allocator, if (event.all_day == 1) "true" else "false");
251
252 try out.append(allocator, '}');
253}
254
255fn appendStringMember(
256 allocator: std.mem.Allocator,
257 out: *std.ArrayListUnmanaged(u8),
258 name: []const u8,
259 value: []const u8,
260) !void {
261 try out.append(allocator, '"');
262 try out.appendSlice(allocator, name);
263 try out.appendSlice(allocator, "\":\"");
264 try bridge_error.appendJsonEscaped(allocator, out, value);
265 try out.append(allocator, '"');
266}
267
268fn appendInt(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), value: i64) !void {
269 var buf: [32]u8 = undefined;
270 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value}));
271}
272
273/// `Long.toString(value)` as a NUL-terminated string, for `NewStringUTF`.
274///
275/// Decimal with a leading `-` and nothing else, which is what `Long.toString`
276/// produces and what the provider parses back out of a selection argument.
277fn decimalZ(buf: []u8, value: i64) ![*:0]const u8 {
278 const text = try std.fmt.bufPrint(buf[0 .. buf.len - 1], "{d}", .{value});
279 buf[text.len] = 0;
280 return @ptrCast(text.ptr);
281}
282
283/// `System.currentTimeMillis()`.
284///
285/// Through JNI rather than a host clock, because it is what the shim's default
286/// window is measured from — and because `std.time` on this toolchain has no
287/// wall-clock call that works without libc. Belongs in a shared Android helper
288/// the moment a second action needs it.
289pub fn currentTimeMillis(j: Jni) !i64 {
290 try j.pushLocalFrame(4);
291 defer _ = j.popLocalFrame(null);
292
293 const system_cls = try j.findClass("java/lang/System");
294 return j.callStaticLongMethodA(
295 system_cls,
296 try j.staticMethodId(system_cls, "currentTimeMillis", "()J"),
297 &.{},
298 );
299}
300
301/// `contentResolver.query(...)`, every row appended to `out` as JSON.
302///
303/// `out` receives the array including its brackets, so a query returning no
304/// cursor at all produces `[]` — which is what `cursor?.use` does in the shim
305/// when the provider hands back null.
306pub fn queryEvents(
307 j: Jni,
308 allocator: std.mem.Allocator,
309 activity: jobject,
310 window: Window,
311 out: *std.ArrayListUnmanaged(u8),
312) !void {
313 try j.pushLocalFrame(24);
314 defer _ = j.popLocalFrame(null);
315
316 const string_cls = try j.findClass("java/lang/String");
317
318 const projection_array = try j.newObjectArray(projection.len, string_cls);
319 inline for (projection, 0..) |name, i| {
320 try j.setObjectArrayElement(projection_array, i, try j.newStringUtf(name));
321 }
322
323 // `startTime.toString()` — `Long.toString`, which is decimal with a
324 // leading `-` and nothing else, so `bufPrintIntToSlice` matches it.
325 var start_buf: [24]u8 = undefined;
326 var end_buf: [24]u8 = undefined;
327 const args_array = try j.newObjectArray(2, string_cls);
328 try j.setObjectArrayElement(args_array, 0, try j.newStringUtf(try decimalZ(&start_buf, window.start)));
329 try j.setObjectArrayElement(args_array, 1, try j.newStringUtf(try decimalZ(&end_buf, window.end)));
330
331 const events_cls = try j.findClass("android/provider/CalendarContract$Events");
332 const content_uri = try j.staticObjectField(
333 events_cls,
334 try j.staticFieldId(events_cls, "CONTENT_URI", "Landroid/net/Uri;"),
335 );
336
337 const activity_cls = try j.objectClass(activity);
338 const resolver = try j.callObjectMethod(
339 activity,
340 try j.methodId(activity_cls, "getContentResolver", "()Landroid/content/ContentResolver;"),
341 );
342
343 const resolver_cls = try j.objectClass(resolver);
344 const cursor = try j.callObjectMethodA(
345 resolver,
346 try j.methodId(
347 resolver_cls,
348 "query",
349 "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;",
350 ),
351 &.{
352 .{ .l = content_uri },
353 .{ .l = projection_array },
354 .{ .l = try j.newStringUtf(selection) },
355 .{ .l = args_array },
356 .{ .l = try j.newStringUtf(sort_order) },
357 },
358 );
359
360 try out.append(allocator, '[');
361 defer out.append(allocator, ']') catch {};
362
363 // A provider that cannot answer returns null rather than an empty cursor,
364 // and `cursor?.use` skips the loop. An empty array is the honest reply:
365 // the shim resolves with one too.
366 if (cursor == null) return;
367
368 const cursor_cls = try j.objectClass(cursor);
369 const move_to_next = try j.methodId(cursor_cls, "moveToNext", "()Z");
370 const get_string = try j.methodId(cursor_cls, "getString", "(I)Ljava/lang/String;");
371 const get_long = try j.methodId(cursor_cls, "getLong", "(I)J");
372 const get_int = try j.methodId(cursor_cls, "getInt", "(I)I");
373 const close = try j.methodId(cursor_cls, "close", "()V");
374
375 // `use` closes the cursor however the block leaves — including on the way
376 // out of an error, which is what leaks a provider connection otherwise.
377 defer j.callVoidMethodA(cursor, close, &.{}) catch {};
378
379 var count: usize = 0;
380 while (try j.callBooleanMethodA(cursor, move_to_next, &.{})) {
381 // Each row's strings are local references, and the JVM guarantees only
382 // sixteen slots. A frame per row is what keeps a long calendar from
383 // overflowing the table, which aborts the process rather than throwing.
384 try j.pushLocalFrame(8);
385 defer _ = j.popLocalFrame(null);
386
387 const event: Event = .{
388 .id = try columnText(j, allocator, cursor, get_string, 0),
389 .title = try columnText(j, allocator, cursor, get_string, 1),
390 .notes = try columnText(j, allocator, cursor, get_string, 2),
391 .start_date = try j.callLongMethodA(cursor, get_long, &.{.{ .i = 3 }}),
392 .end_date = try j.callLongMethodA(cursor, get_long, &.{.{ .i = 4 }}),
393 .location = try columnText(j, allocator, cursor, get_string, 5),
394 .all_day = try j.callIntMethodA(cursor, get_int, &.{.{ .i = 6 }}),
395 };
396
397 if (count != 0) try out.append(allocator, ',');
398 try appendEvent(allocator, out, event);
399 count += 1;
400 }
401}
402
403/// `cursor.getString(index)`, null included.
404///
405/// The allocation outlives the row's local frame because `stringToUtf8` copies
406/// into `allocator` — the `jstring` itself does not.
407fn columnText(
408 j: Jni,
409 allocator: std.mem.Allocator,
410 cursor: jobject,
411 get_string: jni.jmethodID,
412 index: i32,
413) !?[]const u8 {
414 const value = try j.callObjectMethodA(cursor, get_string, &.{.{ .i = index }});
415 if (value == null) return null;
416 return try j.stringToUtf8(allocator, value);
417}
418
142419// =============================================================================
143420// Tests
144421// =============================================================================
@@ -193,3 +470,156 @@ test "the action name and its globals match the shim exactly" {
193470 try testing.expectEqualStrings("_craftDeleteEventResolve", resolve_global);
194471 try testing.expectEqualStrings("_craftDeleteEventReject", reject_global);
195472}
473
474test "an absent window falls back the way the shim's does" {
475 const now: i64 = 1_700_000_000_000;
476
477 // Both supplied: used as given, including an end before the start. The
478 // shim does not correct that and neither does this.
479 try testing.expectEqual(Window{ .start = 10, .end = 20 }, windowFor(10, 20, now));
480 try testing.expectEqual(Window{ .start = 30, .end = 20 }, windowFor(30, 20, now));
481
482 // `if (startDateMs > 0)` — zero and negative both mean "now", which is why
483 // this is not `>= 0`.
484 try testing.expectEqual(now, windowFor(0, 20, now).start);
485 try testing.expectEqual(now, windowFor(-1, 20, now).start);
486
487 // Thirty days, in milliseconds.
488 try testing.expectEqual(now + 2_592_000_000, windowFor(0, 0, now).end);
489 try testing.expectEqual(@as(i64, 10 + 2_592_000_000), windowFor(10, 0, now).end);
490}
491
492test "a window whose default end overflows wraps rather than trapping" {
493 // Kotlin `Long` addition wraps. A page passing a start near Long.MAX_VALUE
494 // gets a nonsensical window and an empty result from the shim; a `+` here
495 // would instead panic in a debug build, which is a different bug.
496 const near_max: i64 = std.math.maxInt(i64) - 5;
497 try testing.expectEqual(near_max +% 2_592_000_000, windowFor(near_max, 0, 0).end);
498}
499
500fn renderEvents(events_in: []const Event) ![]u8 {
501 var out: std.ArrayListUnmanaged(u8) = .empty;
502 errdefer out.deinit(testing.allocator);
503 try out.append(testing.allocator, '[');
504 for (events_in, 0..) |event, i| {
505 if (i != 0) try out.append(testing.allocator, ',');
506 try appendEvent(testing.allocator, &out, event);
507 }
508 try out.append(testing.allocator, ']');
509 return out.toOwnedSlice(testing.allocator);
510}
511
512const sample: Event = .{
513 .id = "17",
514 .title = "Standup",
515 .notes = "",
516 .start_date = 1_700_000_000_000,
517 .end_date = 1_700_000_900_000,
518 .location = "Room 2",
519 .all_day = 0,
520};
521
522test "an event prints the keys the shim's JSONObject prints, in that order" {
523 const json = try renderEvents(&.{sample});
524 defer testing.allocator.free(json);
525
526 // JSONObject is a LinkedHashMap, so the shim emits insertion order — the
527 // order of its `apply` block. Asserted as a whole string rather than key
528 // by key, because the order is the part that a rewrite would lose.
529 try testing.expectEqualStrings(
530 \\[{"id":"17","title":"Standup","notes":"","startDate":1700000000000,"endDate":1700000900000,"location":"Room 2","isAllDay":false}]
531 , json);
532}
533
534test "a null id leaves the key out, and null text becomes empty" {
535 // `put(name, null)` removes the mapping rather than storing JSON null, so
536 // the shim's object has no `id` at all. The other five columns are read
537 // with `?: ""`, which is a different answer to the same null.
538 const json = try renderEvents(&.{.{
539 .id = null,
540 .title = null,
541 .notes = null,
542 .start_date = 0,
543 .end_date = 0,
544 .location = null,
545 .all_day = 1,
546 }});
547 defer testing.allocator.free(json);
548
549 try testing.expectEqualStrings(
550 \\[{"title":"","notes":"","startDate":0,"endDate":0,"location":"","isAllDay":true}]
551 , json);
552}
553
554test "isAllDay is the shim's == 1 and not a truthiness test" {
555 for ([_]struct { value: i32, expected: []const u8 }{
556 .{ .value = 0, .expected = "\"isAllDay\":false" },
557 .{ .value = 1, .expected = "\"isAllDay\":true" },
558 // `getInt(6) == 1` says false here, and a `!= 0` would say true. The
559 // column only ever holds 0 or 1, so this row is the one that would go
560 // unnoticed — which is why it is written down.
561 .{ .value = 2, .expected = "\"isAllDay\":false" },
562 .{ .value = -1, .expected = "\"isAllDay\":false" },
563 }) |case| {
564 var event = sample;
565 event.all_day = case.value;
566 const json = try renderEvents(&.{event});
567 defer testing.allocator.free(json);
568
569 try testing.expect(std.mem.indexOf(u8, json, case.expected) != null);
570 }
571}
572
573test "a title carrying a quote survives as JSON rather than breaking the reply" {
574 // The payload goes to `events.settle`, which sends it as written. An event
575 // called `Bob's "1:1"` is ordinary, and it is exactly what an unescaped
576 // build would turn into a script that does not parse.
577 var event = sample;
578 event.title = "Bob's \"1:1\"";
579 event.location = "line\nbreak";
580
581 const json = try renderEvents(&.{event});
582 defer testing.allocator.free(json);
583
584 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
585 defer parsed.deinit();
586
587 const first = parsed.value.array.items[0].object;
588 try testing.expectEqualStrings("Bob's \"1:1\"", first.get("title").?.string);
589 try testing.expectEqualStrings("line\nbreak", first.get("location").?.string);
590 try testing.expect(first.get("id") != null);
591}
592
593test "an empty calendar and a full one both parse" {
594 const empty = try renderEvents(&.{});
595 defer testing.allocator.free(empty);
596 try testing.expectEqualStrings("[]", empty);
597
598 var second = sample;
599 second.id = "18";
600 const two = try renderEvents(&.{ sample, second });
601 defer testing.allocator.free(two);
602
603 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, two, .{});
604 defer parsed.deinit();
605 try testing.expectEqual(@as(usize, 2), parsed.value.array.items.len);
606}
607
608test "the query strings are the shim's, character for character" {
609 // The selection and sort order are built from the same column constants
610 // the projection uses, so a typo would have to be in one place. Asserted
611 // against literals anyway: these are what the provider parses, and the
612 // constants they are built from are not independently checked.
613 try testing.expectEqualStrings("(dtstart >= ?) AND (dtstart <= ?)", selection);
614 try testing.expectEqualStrings("dtstart ASC", sort_order);
615 try testing.expectEqual(@as(usize, 7), projection.len);
616 try testing.expectEqualStrings("_id", projection[0]);
617 try testing.expectEqualStrings("eventLocation", projection[5]);
618 try testing.expectEqualStrings("allDay", projection[6]);
619}
620
621test "getCalendarEvents names its action and globals as the shim does" {
622 try testing.expectEqualStrings("getCalendarEvents", A.get_calendar_events);
623 try testing.expectEqualStrings("_craftCalendarResolve", list_resolve_global);
624 try testing.expectEqualStrings("_craftCalendarReject", list_reject_global);
625}
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.
69const max_not_yet_migrated: usize = 85;
6870
6971/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
7072///