@@ -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. 3 3 //! 4 4 //! ## What the shim does 5 5 //! ⋯ 34 lines Show the 34 lines above this hunk @@ -40,11 +40,18 @@ const jobject = jni.jobject; 40 40 41 41 pub const A = struct { 42 42 pub const delete_calendar_event = "deleteCalendarEvent"; 43 + pub const get_calendar_events = "getCalendarEvents"; 43 44 }; 44 45 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. 46 51 pub const resolve_global = "_craftDeleteEventResolve"; 47 52 pub const reject_global = "_craftDeleteEventReject"; 53 + pub const list_resolve_global = "_craftCalendarResolve"; 54 + pub const list_reject_global = "_craftCalendarReject"; 48 55 49 56 /// `Long.parseLong(text)`, or null where Java would throw. 50 57 /// ⋯ 88 lines Show the 88 lines above this hunk @@ -139,6 +146,276 @@ pub fn rejectPayload(allocator: std.mem.Allocator, message: []const u8) ![]u8 { 139 146 return payload.toOwnedSlice(allocator); 140 147 } 141 148 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 + 160 + const col_id = "_id"; 161 + const col_title = "title"; 162 + const col_description = "description"; 163 + const col_dtstart = "dtstart"; 164 + const col_dtend = "dtend"; 165 + const col_event_location = "eventLocation"; 166 + const 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. 170 + const 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 + 180 + const selection = "(" ++ col_dtstart ++ " >= ?) AND (" ++ col_dtstart ++ " <= ?)"; 181 + const sort_order = col_dtstart ++ " ASC"; 182 + 183 + /// Thirty days, the shim's default window: `30L * 24 * 60 * 60 * 1000`. 184 + const thirty_days_ms: i64 = 30 * 24 * 60 * 60 * 1000; 185 + 186 + pub 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. 193 + pub 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. 209 + pub 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. 228 + pub 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 + 255 + fn 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 + 268 + fn 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. 277 + fn 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. 289 + pub 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. 306 + pub 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. 407 + fn 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 + 142 419 // ============================================================================= 143 420 // Tests 144 421 // ============================================================================= ⋯ 48 lines Show the 48 lines above this hunk @@ -193,3 +470,156 @@ test "the action name and its globals match the shim exactly" { 193 470 try testing.expectEqualStrings("_craftDeleteEventResolve", resolve_global); 194 471 try testing.expectEqualStrings("_craftDeleteEventReject", reject_global); 195 472 } 473 + 474 + test "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 + 492 + test "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 + 500 + fn 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 + 512 + const 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 + 522 + test "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 + 534 + test "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 + 554 + test "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 + 573 + test "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 + 593 + test "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 + 608 + test "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 + 621 + test "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 + }