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/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.