also looking at this
fix(ios): schedule a notification with the data the page gave it
#262
8 files
+242
-22
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -393,6 +393,20 @@ describe('Craft iOS builder', () => { | ||
| 393 | 393 | expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationReceived"') |
| 394 | 394 | }) |
| 395 | 395 | |
| 396 | it('schedules a local notification with the data the page gave it', async () => { | |
| 397 | // #258: `data` never reached userInfo, so a tap on a scheduled | |
| 398 | // notification handed the page {} and it had nothing to route on. | |
| 399 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-notification-data-')) | |
| 400 | await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output, config: { enableLocalNotifications: true } }) | |
| 401 | const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8') | |
| 402 | ||
| 403 | const schedule = swift.slice(swift.indexOf('private func scheduleLocalNotification(')) | |
| 404 | const body = schedule.slice(0, schedule.indexOf('\n }\n')) | |
| 405 | expect(body).toContain('if let info = data["data"] as? [String: Any] { content.userInfo = info }') | |
| 406 | // Set on the content the request is made from, before it is filed. | |
| 407 | expect(body.indexOf('content.userInfo = info')).toBeLessThan(body.indexOf('UNNotificationRequest(identifier: id, content: content')) | |
| 408 | }) | |
| 409 | ||
| 396 | 410 | it('settles calendar callbacks only after a real EventKit operation', async () => { |
| 397 | 411 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-')) |
| 398 | 412 | await init({ |
| @@ -4433,6 +4433,10 @@ struct CraftWebView: UIViewRepresentable { | ||
| 4433 | 4433 | content.body = data["body"] as? String ?? "" |
| 4434 | 4434 | if let subtitle = data["subtitle"] as? String { content.subtitle = subtitle } |
| 4435 | 4435 | if let badge = data["badge"] as? Int { content.badge = NSNumber(value: badge) } |
| 4436 | // The page's `data`, which is what a tap (#255) and an arrival | |
| 4437 | // (#256) hand back. It was dropped, so a tap on a scheduled | |
| 4438 | // notification reached the page as {} (#258). | |
| 4439 | if let info = data["data"] as? [String: Any] { content.userInfo = info } | |
| 4436 | 4440 | content.sound = .default |
| 4437 | 4441 | |
| 4438 | 4442 | let id = data["id"] as? String ?? UUID().uuidString |
| @@ -707,12 +707,16 @@ const Schedule = struct { | ||
| 707 | 707 | subtitle: ?[]u8, |
| 708 | 708 | badge: ?i64, |
| 709 | 709 | trigger: Trigger, |
| 710 | /// The page's `data` object as JSON text, for `userInfo`. Text here so | |
| 711 | /// parsing stays pure; `buildRequest` turns it into an NSDictionary. | |
| 712 | user_info: ?[]u8 = null, | |
| 710 | 713 | |
| 711 | 714 | fn deinit(self: *Schedule, allocator: std.mem.Allocator) void { |
| 712 | 715 | if (self.id) |v| allocator.free(v); |
| 713 | 716 | allocator.free(self.title); |
| 714 | 717 | allocator.free(self.body); |
| 715 | 718 | if (self.subtitle) |v| allocator.free(v); |
| 719 | if (self.user_info) |v| allocator.free(v); | |
| 716 | 720 | self.* = undefined; |
| 717 | 721 | } |
| 718 | 722 | }; |
| @@ -759,6 +763,16 @@ fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule { | ||
| 759 | 763 | } else null; |
| 760 | 764 | errdefer if (id) |v| allocator.free(v); |
| 761 | 765 | |
| 766 | // `data` rides along as the notification's userInfo, which is what a tap | |
| 767 | // (#255) and an arrival (#256) hand the page. It was dropped, so a | |
| 768 | // scheduled notification reached the page as `{}` (#258). An object only, | |
| 769 | // as `as? [String: Any]` reads it. | |
| 770 | const user_info: ?[]u8 = if (notification.get("data")) |v| switch (v) { | |
| 771 | .object => try jsonText(allocator, v), | |
| 772 | else => null, | |
| 773 | } else null; | |
| 774 | errdefer if (user_info) |v| allocator.free(v); | |
| 775 | ||
| 762 | 776 | return .{ |
| 763 | 777 | .id = id, |
| 764 | 778 | .title = title, |
| @@ -766,9 +780,17 @@ fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule { | ||
| 766 | 780 | .subtitle = subtitle, |
| 767 | 781 | .badge = numberOr(notification.get("badge")), |
| 768 | 782 | .trigger = triggerFrom(notification), |
| 783 | .user_info = user_info, | |
| 769 | 784 | }; |
| 770 | 785 | } |
| 771 | 786 | |
| 787 | fn jsonText(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 { | |
| 788 | var out: std.Io.Writer.Allocating = .init(allocator); | |
| 789 | defer out.deinit(); | |
| 790 | try std.json.Stringify.value(value, .{}, &out.writer); | |
| 791 | return out.toOwnedSlice(); | |
| 792 | } | |
| 793 | ||
| 772 | 794 | fn stringOr(value: ?std.json.Value, fallback: []const u8) []const u8 { |
| 773 | 795 | const v = value orelse return fallback; |
| 774 | 796 | return switch (v) { |
| @@ -827,6 +849,9 @@ const ScheduleSels = struct { | ||
| 827 | 849 | request_with: Id, |
| 828 | 850 | add_request: Id, |
| 829 | 851 | uuid_string: Id, |
| 852 | set_user_info: Id, | |
| 853 | data_with_bytes: Id, | |
| 854 | json_object_with_data: Id, | |
| 830 | 855 | |
| 831 | 856 | fn resolve() !ScheduleSels { |
| 832 | 857 | if (!is_darwin) return error.UnsupportedPlatform; |
| @@ -848,6 +873,9 @@ const ScheduleSels = struct { | ||
| 848 | 873 | .request_with = try selector("requestWithIdentifier:content:trigger:"), |
| 849 | 874 | .add_request = try selector("addNotificationRequest:withCompletionHandler:"), |
| 850 | 875 | .uuid_string = try selector("UUIDString"), |
| 876 | .set_user_info = try selector("setUserInfo:"), | |
| 877 | .data_with_bytes = try selector("dataWithBytes:length:"), | |
| 878 | .json_object_with_data = try selector("JSONObjectWithData:options:error:"), | |
| 851 | 879 | }; |
| 852 | 880 | } |
| 853 | 881 | }; |
| @@ -1113,6 +1141,7 @@ fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id { | ||
| 1113 | 1141 | try setStringField(content, sels.set_title, schedule.title, sels); |
| 1114 | 1142 | try setStringField(content, sels.set_body, schedule.body, sels); |
| 1115 | 1143 | if (schedule.subtitle) |sub| try setStringField(content, sels.set_subtitle, sub, sels); |
| 1144 | if (schedule.user_info) |json| objc.msgSendVoid1(content, sels.set_user_info, try jsonObject(json, sels)); | |
| 1116 | 1145 | |
| 1117 | 1146 | if (schedule.badge) |badge| { |
| 1118 | 1147 | const NSNumber = objc.objc_getClass("NSNumber") orelse return error.ClassNotFound; |
| @@ -1141,6 +1170,25 @@ fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id { | ||
| 1141 | 1170 | return request; |
| 1142 | 1171 | } |
| 1143 | 1172 | |
| 1173 | /// The page's `data` as the NSDictionary `userInfo` takes, read back through | |
| 1174 | /// NSJSONSerialization into the same Foundation types WebKit hands the Swift | |
| 1175 | /// side. JSON the page sent always reads; refusing when it does not beats | |
| 1176 | /// scheduling a notification that would reach the page as `{}` again. | |
| 1177 | fn jsonObject(json: []const u8, sels: ScheduleSels) !Id { | |
| 1178 | const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound; | |
| 1179 | const DataFn = *const fn (Id, Id, [*]const u8, c_ulong) callconv(.c) Id; | |
| 1180 | const make_data: DataFn = @ptrCast(&objc.objc_msgSend); | |
| 1181 | const bytes = make_data(NSData, sels.data_with_bytes, json.ptr, json.len); | |
| 1182 | if (bytes == null) return error.NoData; | |
| 1183 | ||
| 1184 | const NSJSONSerialization = objc.objc_getClass("NSJSONSerialization") orelse return error.ClassNotFound; | |
| 1185 | const ReadFn = *const fn (Id, Id, Id, c_ulong, ?*Id) callconv(.c) Id; | |
| 1186 | const read: ReadFn = @ptrCast(&objc.objc_msgSend); | |
| 1187 | const object = read(NSJSONSerialization, sels.json_object_with_data, bytes, 0, null); | |
| 1188 | if (object == null) return error.UserInfoUnreadable; | |
| 1189 | return object; | |
| 1190 | } | |
| 1191 | ||
| 1144 | 1192 | /// A nil trigger is legal and means "deliver now" — see `Trigger`. |
| 1145 | 1193 | fn buildTrigger(trigger: Trigger, sels: ScheduleSels) !Id { |
| 1146 | 1194 | switch (trigger) { |
| @@ -1371,6 +1419,33 @@ test "the payload is read the way the spec reads it" { | ||
| 1371 | 1419 | ); |
| 1372 | 1420 | } |
| 1373 | 1421 | |
| 1422 | test "the page's data travels as userInfo, and only an object does" { | |
| 1423 | // #258: `data` was never read, so a tap on a scheduled notification handed | |
| 1424 | // the page `{}` and "open the thing this is about" had nothing to open. | |
| 1425 | const allocator = testing.allocator; | |
| 1426 | ||
| 1427 | var with = try parseSchedule(allocator, "{\"notification\":{\"title\":\"t\",\"data\":{\"screen\":\"plant-id\",\"n\":2,\"deep\":{\"ok\":true}}}}"); | |
| 1428 | defer with.deinit(allocator); | |
| 1429 | const text = with.user_info orelse return error.DataDropped; | |
| 1430 | const round = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); | |
| 1431 | defer round.deinit(); | |
| 1432 | try testing.expectEqualStrings("plant-id", round.value.object.get("screen").?.string); | |
| 1433 | try testing.expectEqual(@as(i64, 2), round.value.object.get("n").?.integer); | |
| 1434 | try testing.expect(round.value.object.get("deep").?.object.get("ok").?.bool); | |
| 1435 | ||
| 1436 | // `as? [String: Any]`: anything but an object is no userInfo at all. | |
| 1437 | for ([_][]const u8{ | |
| 1438 | "{\"notification\":{\"title\":\"t\"}}", | |
| 1439 | "{\"notification\":{\"data\":\"plant-id\"}}", | |
| 1440 | "{\"notification\":{\"data\":[1,2]}}", | |
| 1441 | "{\"notification\":{\"data\":null}}", | |
| 1442 | }) |payload| { | |
| 1443 | var without = try parseSchedule(allocator, payload); | |
| 1444 | defer without.deinit(allocator); | |
| 1445 | try testing.expect(without.user_info == null); | |
| 1446 | } | |
| 1447 | } | |
| 1448 | ||
| 1374 | 1449 | test "a non-positive delay delivers now instead of raising" { |
| 1375 | 1450 | // The spec passes `delay / 1000` to `UNTimeIntervalNotificationTrigger` |
| 1376 | 1451 | // unguarded, and an interval of zero or less raises an uncatchable ObjC |