also looking at this
fix(sdk): schedule a notification for its scheduleAt, not immediately
#263
3 files
+73
-3
| @@ -24,7 +24,7 @@ import type { | ||
| 24 | 24 | LiveActivityHandle, |
| 25 | 25 | LiveActivityOptions, |
| 26 | 26 | } from '../api/mobile' |
| 27 | import { normalizeDeepLinkURL, pushNotifications, watchConnectivity } from '../api/mobile' | |
| 27 | import { bridgeNotification, normalizeDeepLinkURL, notifications, pushNotifications, watchConnectivity } from '../api/mobile' | |
| 28 | 28 | |
| 29 | 29 | describe('Mobile deep links', () => { |
| 30 | 30 | it('normalizes native payloads to the public string contract', () => { |
| @@ -70,6 +70,53 @@ describe('Mobile notification taps', () => { | ||
| 70 | 70 | }) |
| 71 | 71 | }) |
| 72 | 72 | |
| 73 | describe('Mobile scheduled notifications', () => { | |
| 74 | // #261: the bridges schedule from `delay`, and neither reads `scheduleAt`, | |
| 75 | // so a reminder meant for an hour from now fired at once. | |
| 76 | const NOW = 1_700_000_000_000 | |
| 77 | ||
| 78 | it('turns scheduleAt into a delay from now, and sends no scheduleAt', () => { | |
| 79 | const sent = bridgeNotification({ title: 'Reminder', body: 'Break', scheduleAt: NOW + 3_600_000, data: { screen: 'plant-id' } }, NOW) | |
| 80 | expect(sent).toEqual({ title: 'Reminder', body: 'Break', delay: 3_600_000, data: { screen: 'plant-id' } }) | |
| 81 | expect('scheduleAt' in sent).toBe(false) | |
| 82 | }) | |
| 83 | ||
| 84 | it('delivers now for a moment already here or past, without a zero delay', () => { | |
| 85 | // A zero delay raises inside UserNotifications on the Swift side, so | |
| 86 | // "now" is no delay at all, which both bridges deliver immediately. | |
| 87 | for (const scheduleAt of [NOW, NOW - 5_000]) { | |
| 88 | const sent = bridgeNotification({ title: 'Now', scheduleAt }, NOW) | |
| 89 | expect(sent).toEqual({ title: 'Now' }) | |
| 90 | } | |
| 91 | // A fraction of a millisecond still waits: Android reads whole ms. | |
| 92 | expect(bridgeNotification({ title: 'Soon', scheduleAt: NOW + 0.4 }, NOW)).toEqual({ title: 'Soon', delay: 1 }) | |
| 93 | }) | |
| 94 | ||
| 95 | it('leaves a notification with no scheduleAt as it was', () => { | |
| 96 | expect(bridgeNotification({ title: 'Plain', badge: 2 }, NOW)).toEqual({ title: 'Plain', badge: 2 }) | |
| 97 | }) | |
| 98 | ||
| 99 | it('schedules through the bridge with the delay, not scheduleAt', async () => { | |
| 100 | const previousWindow = (globalThis as any).window | |
| 101 | const sent: unknown[] = [] | |
| 102 | ;(globalThis as any).window = { craft: { notifications: { schedule: async (options: unknown) => { sent.push(options) } } } } | |
| 103 | try { | |
| 104 | const before = Date.now() | |
| 105 | await notifications.schedule({ title: 'Reminder', scheduleAt: before + 3_600_000 }) | |
| 106 | const after = Date.now() | |
| 107 | expect(sent).toHaveLength(1) | |
| 108 | const options = sent[0] as { delay?: number, scheduleAt?: number } | |
| 109 | expect(options.scheduleAt).toBeUndefined() | |
| 110 | expect(options.delay).toBeGreaterThanOrEqual(3_600_000 - (after - before)) | |
| 111 | expect(options.delay).toBeLessThanOrEqual(3_600_000) | |
| 112 | } | |
| 113 | finally { | |
| 114 | if (previousWindow === undefined) delete (globalThis as any).window | |
| 115 | else (globalThis as any).window = previousWindow | |
| 116 | } | |
| 117 | }) | |
| 118 | }) | |
| 119 | ||
| 73 | 120 | describe('Mobile notification arrivals', () => { |
| 74 | 121 | // #256: the bridge's onReceive, passed through, and the bare event when a |
| 75 | 122 | // bridge predates it. |
| @@ -1179,6 +1179,27 @@ export interface NotificationOptions { | ||
| 1179 | 1179 | scheduleAt?: number |
| 1180 | 1180 | } |
| 1181 | 1181 | |
| 1182 | /** A notification as the bridges read it: `delay` in place of `scheduleAt`. */ | |
| 1183 | export type BridgeNotification = Omit<NotificationOptions, 'scheduleAt'> & { delay?: number } | |
| 1184 | ||
| 1185 | /** | |
| 1186 | * The bridges' spelling of a notification: `scheduleAt` becomes `delay`. | |
| 1187 | * | |
| 1188 | * Both bridges schedule from `delay`, milliseconds from now, and iOS also from | |
| 1189 | * `timestamp`; neither reads `scheduleAt`, so a reminder meant for an hour | |
| 1190 | * from now fired at once (#261). `delay` rather than `timestamp` because | |
| 1191 | * Android reads only `delay`, and because an interval is what an instant | |
| 1192 | * means, where iOS's calendar trigger would move with the device's time zone. | |
| 1193 | * A moment already past sends no delay at all, which both bridges deliver | |
| 1194 | * now: a zero delay raises inside UserNotifications on the Swift side. | |
| 1195 | */ | |
| 1196 | export function bridgeNotification(options: NotificationOptions, now: number = Date.now()): BridgeNotification { | |
| 1197 | const { scheduleAt, ...rest } = options | |
| 1198 | if (scheduleAt === undefined) return rest | |
| 1199 | const wait = Math.ceil(scheduleAt - now) | |
| 1200 | return wait > 0 ? { ...rest, delay: wait } : rest | |
| 1201 | } | |
| 1202 | ||
| 1182 | 1203 | /** |
| 1183 | 1204 | * Local notifications API. |
| 1184 | 1205 | * |
| @@ -1226,7 +1247,7 @@ export const notifications = { | ||
| 1226 | 1247 | async schedule(options: NotificationOptions): Promise<void> { |
| 1227 | 1248 | const craft = getCraftMobile() |
| 1228 | 1249 | if (typeof window !== 'undefined' && craft?.notifications) { |
| 1229 | return craft.notifications.schedule(options) | |
| 1250 | return craft.notifications.schedule(bridgeNotification(options)) | |
| 1230 | 1251 | } |
| 1231 | 1252 | throw new Error('Scheduled notifications not available in web') |
| 1232 | 1253 | }, |
| @@ -1637,7 +1658,7 @@ interface CraftMobileBridge { | ||
| 1637 | 1658 | } |
| 1638 | 1659 | notifications?: { |
| 1639 | 1660 | show(options: NotificationOptions): Promise<void> |
| 1640 | schedule(options: NotificationOptions): Promise<void> | |
| 1661 | schedule(options: BridgeNotification): Promise<void> | |
| 1641 | 1662 | cancelAll(): Promise<void> |
| 1642 | 1663 | setBadge(count: number): Promise<void> |
| 1643 | 1664 | } |
| @@ -1258,6 +1258,8 @@ export interface NotificationData { | ||
| 1258 | 1258 | badge?: number; |
| 1259 | 1259 | timestamp?: number; |
| 1260 | 1260 | delay?: number; |
| 1261 | /** Handed back to the page when the notification is tapped or arrives. */ | |
| 1262 | data?: Record<string, any>; | |
| 1261 | 1263 | } |
| 1262 | 1264 | |
| 1263 | 1265 | export interface PendingNotification { |