ReviewOS

also looking at this

craft-native/craft

fix(ios): schedule a notification with the data the page gave it

#262
Merged glennmichael123 wants to merge fix/ios-notification-data into main
8 files +242 -22
packages/zig/src/bridge_mobile_notifications.zigmodified+75-0
Changes to packages/zig/src/bridge_mobile_notifications.zig
@@ -707,12 +707,16 @@ const Schedule = struct {
707707 subtitle: ?[]u8,
708708 badge: ?i64,
709709 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,
710713
711714 fn deinit(self: *Schedule, allocator: std.mem.Allocator) void {
712715 if (self.id) |v| allocator.free(v);
713716 allocator.free(self.title);
714717 allocator.free(self.body);
715718 if (self.subtitle) |v| allocator.free(v);
719 if (self.user_info) |v| allocator.free(v);
716720 self.* = undefined;
717721 }
718722};
@@ -759,6 +763,16 @@ fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule {
759763 } else null;
760764 errdefer if (id) |v| allocator.free(v);
761765
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
762776 return .{
763777 .id = id,
764778 .title = title,
@@ -766,9 +780,17 @@ fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule {
766780 .subtitle = subtitle,
767781 .badge = numberOr(notification.get("badge")),
768782 .trigger = triggerFrom(notification),
783 .user_info = user_info,
769784 };
770785}
771786
787fn 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
772794fn stringOr(value: ?std.json.Value, fallback: []const u8) []const u8 {
773795 const v = value orelse return fallback;
774796 return switch (v) {
@@ -827,6 +849,9 @@ const ScheduleSels = struct {
827849 request_with: Id,
828850 add_request: Id,
829851 uuid_string: Id,
852 set_user_info: Id,
853 data_with_bytes: Id,
854 json_object_with_data: Id,
830855
831856 fn resolve() !ScheduleSels {
832857 if (!is_darwin) return error.UnsupportedPlatform;
@@ -848,6 +873,9 @@ const ScheduleSels = struct {
848873 .request_with = try selector("requestWithIdentifier:content:trigger:"),
849874 .add_request = try selector("addNotificationRequest:withCompletionHandler:"),
850875 .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:"),
851879 };
852880 }
853881};
@@ -1113,6 +1141,7 @@ fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id {
11131141 try setStringField(content, sels.set_title, schedule.title, sels);
11141142 try setStringField(content, sels.set_body, schedule.body, sels);
11151143 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));
11161145
11171146 if (schedule.badge) |badge| {
11181147 const NSNumber = objc.objc_getClass("NSNumber") orelse return error.ClassNotFound;
@@ -1141,6 +1170,25 @@ fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id {
11411170 return request;
11421171}
11431172
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.
1177fn 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
11441192/// A nil trigger is legal and means "deliver now" — see `Trigger`.
11451193fn buildTrigger(trigger: Trigger, sels: ScheduleSels) !Id {
11461194 switch (trigger) {
@@ -1371,6 +1419,33 @@ test "the payload is read the way the spec reads it" {
13711419 );
13721420}
13731421
1422test "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
13741449test "a non-positive delay delivers now instead of raising" {
13751450 // The spec passes `delay / 1000` to `UNTimeIntervalNotificationTrigger`
13761451 // unguarded, and an interval of zero or less raises an uncatchable ObjC