| @@ -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 | +} |