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