also looking at this
feat(android): getCalendarEvents and createCalendarEvent in Zig — ratchet 86 → 84
#158
7 files
+1167
-20
| @@ -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 | //! |
| @@ -40,11 +40,21 @@ 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"; | |
| 44 | pub const create_calendar_event = "createCalendarEvent"; | |
| 43 | 45 | }; |
| 44 | 46 | |
| 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. | |
| 46 | 52 | pub const resolve_global = "_craftDeleteEventResolve"; |
| 47 | 53 | pub const reject_global = "_craftDeleteEventReject"; |
| 54 | pub const list_resolve_global = "_craftCalendarResolve"; | |
| 55 | pub const list_reject_global = "_craftCalendarReject"; | |
| 56 | pub const create_resolve_global = "_craftCreateEventResolve"; | |
| 57 | pub const create_reject_global = "_craftCreateEventReject"; | |
| 48 | 58 | |
| 49 | 59 | /// `Long.parseLong(text)`, or null where Java would throw. |
| 50 | 60 | /// |
| @@ -115,30 +125,538 @@ pub fn deleteEvent(j: Jni, activity: jobject, id: i64) !void { | ||
| 115 | 125 | /// call being inline — `events.settle` sends the payload as written, so a raw |
| 116 | 126 | /// message would emit a script that does not parse. See the module comment. |
| 117 | 127 | pub 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); | |
| 121 | 129 | } |
| 122 | 130 | |
| 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. | |
| 124 | 135 | /// |
| 125 | 136 | /// 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. | |
| 129 | pub 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. | |
| 140 | pub fn jsonString(allocator: std.mem.Allocator, text: []const u8) ![]u8 { | |
| 130 | 141 | var payload: std.ArrayListUnmanaged(u8) = .empty; |
| 131 | 142 | errdefer payload.deinit(allocator); |
| 132 | 143 | |
| 133 | 144 | // The quotes are this caller's job: `appendJsonEscaped` escapes the |
| 134 | 145 | // contents and writes no delimiters. |
| 135 | 146 | try payload.append(allocator, '"'); |
| 136 | try bridge_error.appendJsonEscaped(allocator, &payload, message); | |
| 147 | try bridge_error.appendJsonEscaped(allocator, &payload, text); | |
| 137 | 148 | try payload.append(allocator, '"'); |
| 138 | 149 | |
| 139 | 150 | return payload.toOwnedSlice(allocator); |
| 140 | 151 | } |
| 141 | 152 | |
| 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 | ||
| 164 | const col_id = "_id"; | |
| 165 | const col_title = "title"; | |
| 166 | const col_description = "description"; | |
| 167 | const col_dtstart = "dtstart"; | |
| 168 | const col_dtend = "dtend"; | |
| 169 | const col_event_location = "eventLocation"; | |
| 170 | const 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. | |
| 174 | const 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 | ||
| 184 | const selection = "(" ++ col_dtstart ++ " >= ?) AND (" ++ col_dtstart ++ " <= ?)"; | |
| 185 | const sort_order = col_dtstart ++ " ASC"; | |
| 186 | ||
| 187 | /// Thirty days, the shim's default window: `30L * 24 * 60 * 60 * 1000`. | |
| 188 | const thirty_days_ms: i64 = 30 * 24 * 60 * 60 * 1000; | |
| 189 | ||
| 190 | pub 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. | |
| 197 | pub 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. | |
| 213 | pub 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. | |
| 232 | pub 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 | ||
| 259 | fn 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 | ||
| 272 | fn 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. | |
| 281 | fn 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. | |
| 293 | pub 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. | |
| 310 | pub 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. | |
| 411 | fn 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. | |
| 450 | const col_calendar_id = "calendar_id"; | |
| 451 | const 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. | |
| 459 | const default_calendar_id: i32 = 1; | |
| 460 | ||
| 461 | /// The event to insert, in the shape `NewCalendarEvent` declares. | |
| 462 | pub 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`. | |
| 477 | pub 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". | |
| 496 | fn 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. | |
| 508 | fn 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. | |
| 520 | fn 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. | |
| 534 | pub 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 | ||
| 604 | fn 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 | ||
| 627 | fn 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 | ||
| 640 | fn 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. | |
| 654 | pub 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 | ||
| 142 | 660 | // ============================================================================= |
| 143 | 661 | // Tests |
| 144 | 662 | // ============================================================================= |
| @@ -173,7 +691,7 @@ test "a rejection message with a quote is escaped rather than emitted raw" { | ||
| 173 | 691 | // The bug that used to be one line away. Before #154 the shim turned an id |
| 174 | 692 | // of `1'x` into `…Reject('For input string: "1'x"')`, which does not parse |
| 175 | 693 | // — 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\""); | |
| 177 | 695 | defer testing.allocator.free(payload); |
| 178 | 696 | |
| 179 | 697 | // 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" { | ||
| 193 | 711 | try testing.expectEqualStrings("_craftDeleteEventResolve", resolve_global); |
| 194 | 712 | try testing.expectEqualStrings("_craftDeleteEventReject", reject_global); |
| 195 | 713 | } |
| 714 | ||
| 715 | test "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 | ||
| 733 | test "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 | ||
| 741 | fn 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 | ||
| 753 | const 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 | ||
| 763 | test "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 | ||
| 775 | test "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 | ||
| 795 | test "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 | ||
| 814 | test "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 | ||
| 834 | test "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 | ||
| 849 | test "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 | ||
| 862 | test "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 | ||
| 870 | fn 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 | ||
| 887 | fn freeEvent(event: NewEvent) void { | |
| 888 | testing.allocator.free(event.title); | |
| 889 | testing.allocator.free(event.notes); | |
| 890 | testing.allocator.free(event.location); | |
| 891 | } | |
| 892 | ||
| 893 | test "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 | ||
| 908 | test "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 | ||
| 923 | test "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 | ||
| 941 | test "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 | ||
| 984 | test "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 | ||
| 994 | test "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 | ||
| 1000 | test "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 | } | |