@chipmobilesdk/rn-notification 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +80 -42
  2. package/README.md +399 -306
  3. package/package.json +2 -1
  4. package/skills/sdk-rn-notification/SKILL.md +68 -3
  5. package/src/channel/migrate.ts +86 -12
  6. package/src/channel/tone-map.ts +130 -90
  7. package/src/channel/validate-tone.ts +61 -0
  8. package/src/contract/channel.ts +188 -89
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +130 -123
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +60 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +123 -123
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +64 -64
  21. package/src/engine/adapter.ts +172 -79
  22. package/src/engine/notify-kit-engine.ts +761 -535
  23. package/src/index.ts +35 -0
  24. package/src/initialize.ts +174 -174
  25. package/src/interaction/index.ts +146 -146
  26. package/src/maintenance/cycle.ts +147 -147
  27. package/src/maintenance/permission-refresh.ts +41 -41
  28. package/src/maintenance/reanchor.ts +95 -95
  29. package/src/maintenance/toggle.ts +60 -60
  30. package/src/maintenance/verify.ts +83 -83
  31. package/src/maintenance/window-refresh.ts +61 -61
  32. package/src/permission/index.ts +183 -183
  33. package/src/permission/state.ts +77 -77
  34. package/src/push/envelope.ts +165 -165
  35. package/src/push/fcm-provider.ts +146 -146
  36. package/src/push/gate.ts +190 -190
  37. package/src/push/instructions.ts +109 -109
  38. package/src/push/provider-adapter.ts +54 -54
  39. package/src/push/signout.ts +65 -65
  40. package/src/reconcile/chunk.ts +53 -53
  41. package/src/reconcile/diff.ts +145 -145
  42. package/src/reconcile/duplicates.ts +73 -73
  43. package/src/reconcile/index.ts +14 -1
  44. package/src/reconcile/truncate.ts +56 -56
  45. package/src/recurrence/clock-guard.ts +71 -71
  46. package/src/recurrence/expand.ts +292 -292
  47. package/src/repeat/expiry.ts +55 -0
  48. package/src/repeat/index.ts +54 -0
  49. package/src/repeat/marker.ts +72 -0
  50. package/src/repeat/place.ts +73 -0
  51. package/src/schedule/anchor.ts +267 -267
  52. package/src/schedule/index.ts +184 -173
  53. package/src/schedule/partition.ts +85 -85
  54. package/src/schedule/resolve-moment.ts +80 -80
  55. package/src/schedule/validate-content.ts +111 -111
  56. package/src/schedule/validate-identifier.ts +126 -114
  57. package/src/state/caps.ts +71 -71
  58. package/src/state/dedup-ledger.ts +35 -39
  59. package/src/state/interaction-buffer.ts +73 -73
  60. package/src/state/mmkv-store.ts +71 -71
  61. package/src/state/permission-history.ts +41 -41
  62. package/src/state/record.ts +258 -246
  63. package/src/state/reset.ts +41 -41
  64. package/src/state/series-ledger.ts +84 -84
  65. package/src/state/store.ts +40 -40
  66. package/src/testing/conformance/index.ts +248 -194
  67. package/src/testing/fake-engine.ts +469 -284
  68. package/src/testing/fake-provider.ts +129 -129
  69. package/src/testing/faults.ts +72 -72
  70. package/src/testing/index.ts +2 -0
  71. package/src/testing/measure.ts +337 -0
@@ -1,284 +1,469 @@
1
- /**
2
- * In-memory engine for tests.
3
- *
4
- * Exists so every decision path is provable with no device, no emulator, and
5
- * no live provider. Its controls simulate the conditions that are otherwise
6
- * only reachable on hardware: virtual time, time-zone travel, permission
7
- * transitions, process death, boot-restoration gaps, and platform truncation.
8
- */
9
-
10
- import type {
11
- NotificationEngineAdapter,
12
- EngineCapabilities,
13
- EngineScheduleRequest,
14
- } from '../engine/adapter';
15
- import type { PendingEntry } from '../contract/notification';
16
- import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
17
- import type { ResolvedTone, ChannelGroup } from '../contract/channel';
18
- import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
19
-
20
- export interface FakeEngineOptions {
21
- readonly pendingCeiling?: number;
22
- readonly supportsExactAlarms?: boolean;
23
- readonly supportsDoNotDisturbBypass?: boolean;
24
- readonly backgroundWindowMs?: number;
25
- readonly initialPermissions?: Partial<Record<PermissionKind, PermissionState>>;
26
- readonly timeZone?: string;
27
- readonly now?: number;
28
- }
29
-
30
- /** Every platform operation the engine was asked to perform, in order. */
31
- export interface RecordedOperation {
32
- readonly kind: 'schedule' | 'cancel' | 'cancelAll' | 'declareTone' | 'retireTone';
33
- readonly id?: string;
34
- readonly at: number;
35
- }
36
-
37
- export interface FakeControls {
38
- /** Operations recorded since the last reset. The idempotence assertion reads this. */
39
- readonly operations: readonly RecordedOperation[];
40
- clearOperations(): void;
41
-
42
- /** Virtual clock, so tests never sleep. */
43
- now(): number;
44
- advanceTime(ms: number): void;
45
- setTime(epochMs: number): void;
46
-
47
- /** Simulates the user travelling. */
48
- timeZone(): string;
49
- setTimeZone(zone: string): void;
50
-
51
- setPermission(kind: PermissionKind, state: PermissionState): void;
52
- setRestrictions(report: Partial<RestrictionReport>): void;
53
-
54
- /**
55
- * Drops entries the platform would have silently discarded past its ceiling,
56
- * so truncation behavior is testable without an iOS device.
57
- */
58
- applyPlatformCeiling(): readonly string[];
59
-
60
- /**
61
- * Simulates the process dying: in-memory listeners are lost while scheduled
62
- * notifications survive, which is exactly what happens on a real device.
63
- */
64
- simulateProcessDeath(): void;
65
-
66
- /** Simulates a manufacturer losing notifications across a reboot. */
67
- simulateBootGap(ids: readonly string[]): void;
68
-
69
- /** Queues an interaction that arrived before any handler existed. */
70
- emitInteraction(event: InteractionEvent): void;
71
- queueInitialInteraction(event: InteractionEvent): void;
72
- }
73
-
74
- export type FakeEngine = NotificationEngineAdapter & FakeControls;
75
-
76
- export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
77
- const scheduled = new Map<string, PendingEntry>();
78
- const channels = new Set<string>();
79
- let operations: RecordedOperation[] = [];
80
- let listeners: ((event: InteractionEvent) => void)[] = [];
81
- let initialInteraction: InteractionEvent | undefined;
82
-
83
- let currentTime = options.now ?? Date.UTC(2026, 0, 1, 12, 0, 0);
84
- let currentZone = options.timeZone ?? 'UTC';
85
-
86
- const permissions: Record<PermissionKind, PermissionState> = {
87
- display: options.initialPermissions?.display ?? 'notAsked',
88
- exactAlarm: options.initialPermissions?.exactAlarm ?? 'notAsked',
89
- };
90
-
91
- let restrictions: RestrictionReport = {
92
- batteryOptimization: 'unrestricted',
93
- autoStart: 'unrestricted',
94
- readAt: currentTime,
95
- };
96
-
97
- const capabilities: EngineCapabilities = {
98
- pendingCeiling: options.pendingCeiling ?? 64,
99
- supportsExactAlarms: options.supportsExactAlarms ?? true,
100
- supportsChannels: true,
101
- supportsTextInputActions: true,
102
- supportsDoNotDisturbBypass: options.supportsDoNotDisturbBypass ?? true,
103
- backgroundWindowMs: options.backgroundWindowMs ?? 30_000,
104
- };
105
-
106
- const record = (kind: RecordedOperation['kind'], id?: string): void => {
107
- operations.push({ kind, id, at: currentTime });
108
- };
109
-
110
- const toPendingEntry = (request: EngineScheduleRequest): PendingEntry => ({
111
- id: request.request.id,
112
- resolvedAt: request.resolvedAt,
113
- anchoring: request.request.moment.anchoring ?? 'wallClock',
114
- anchoringGeneration:
115
- (request.request.moment.anchoring ?? 'wallClock') === 'wallClock' ? currentZone : undefined,
116
- content: request.request.content,
117
- tone: request.request.tone,
118
- routing: request.request.routing,
119
- actions: request.request.actions,
120
- ownerTag: request.request.ownerTag,
121
- groupTag: request.request.groupTag,
122
- seriesId: request.seriesId,
123
- mayBeDelayed: request.approximate,
124
- pushCreated: request.pushCreated ?? false,
125
- });
126
-
127
- return {
128
- name: 'fake',
129
-
130
- async getCapabilities() {
131
- return capabilities;
132
- },
133
-
134
- async schedule(request) {
135
- record('schedule', request.request.id);
136
- scheduled.set(request.request.id, toPendingEntry(request));
137
- },
138
-
139
- async cancel(id) {
140
- record('cancel', id);
141
- scheduled.delete(id);
142
- },
143
-
144
- async cancelMany(ids) {
145
- for (const id of ids) {
146
- record('cancel', id);
147
- scheduled.delete(id);
148
- }
149
- },
150
-
151
- async cancelAll() {
152
- record('cancelAll');
153
- scheduled.clear();
154
- },
155
-
156
- async getPending() {
157
- return [...scheduled.values()].sort((a, b) => a.resolvedAt - b.resolvedAt);
158
- },
159
-
160
- async declareTone(tone: ResolvedTone) {
161
- record('declareTone', tone.android.channelId);
162
- if (channels.has(tone.android.channelId)) {
163
- // Android silently ignores re-declaration. The fake does too, on purpose.
164
- return false;
165
- }
166
- channels.add(tone.android.channelId);
167
- return true;
168
- },
169
-
170
- async retireTone(channelId: string) {
171
- record('retireTone', channelId);
172
- return channels.delete(channelId);
173
- },
174
-
175
- async declareGroups(_groups: readonly ChannelGroup[]) {
176
- // Groups are presentational; the fake accepts them without recording.
177
- },
178
-
179
- async listDeclaredChannels() {
180
- return [...channels];
181
- },
182
-
183
- async getPermission(kind) {
184
- return permissions[kind];
185
- },
186
-
187
- async requestPermission(kind) {
188
- if (permissions[kind] === 'notAsked') {
189
- permissions[kind] = 'granted';
190
- }
191
- return permissions[kind];
192
- },
193
-
194
- async openSettings(_kind) {
195
- // No-op: the fake cannot open a settings screen, and pretending it did would be a lie.
196
- },
197
-
198
- async getRestrictions() {
199
- return { ...restrictions, readAt: currentTime };
200
- },
201
-
202
- subscribeToInteractions(handler): Unsubscribe {
203
- listeners.push(handler);
204
- return () => {
205
- listeners = listeners.filter(l => l !== handler);
206
- };
207
- },
208
-
209
- async takeInitialInteraction() {
210
- const event = initialInteraction;
211
- initialInteraction = undefined;
212
- return event;
213
- },
214
-
215
- // --- FakeControls ------------------------------------------------------
216
-
217
- get operations() {
218
- return operations;
219
- },
220
-
221
- clearOperations() {
222
- operations = [];
223
- },
224
-
225
- now() {
226
- return currentTime;
227
- },
228
-
229
- advanceTime(ms) {
230
- currentTime += ms;
231
- },
232
-
233
- setTime(epochMs) {
234
- currentTime = epochMs;
235
- },
236
-
237
- timeZone() {
238
- return currentZone;
239
- },
240
-
241
- setTimeZone(zone) {
242
- currentZone = zone;
243
- },
244
-
245
- setPermission(kind, state) {
246
- permissions[kind] = state;
247
- },
248
-
249
- setRestrictions(report) {
250
- restrictions = { ...restrictions, ...report };
251
- },
252
-
253
- applyPlatformCeiling() {
254
- const ordered = [...scheduled.values()].sort((a, b) => a.resolvedAt - b.resolvedAt);
255
- const dropped = ordered.slice(capabilities.pendingCeiling);
256
- for (const entry of dropped) {
257
- scheduled.delete(entry.id);
258
- }
259
- return dropped.map(entry => entry.id);
260
- },
261
-
262
- simulateProcessDeath() {
263
- // Scheduled notifications live in the OS and survive; listeners do not.
264
- listeners = [];
265
- operations = [];
266
- },
267
-
268
- simulateBootGap(ids) {
269
- for (const id of ids) {
270
- scheduled.delete(id);
271
- }
272
- },
273
-
274
- emitInteraction(event) {
275
- for (const listener of listeners) {
276
- listener(event);
277
- }
278
- },
279
-
280
- queueInitialInteraction(event) {
281
- initialInteraction = event;
282
- },
283
- };
284
- }
1
+ /**
2
+ * In-memory engine for tests.
3
+ *
4
+ * Exists so every decision path is provable with no device, no emulator, and
5
+ * no live provider. Its controls simulate the conditions that are otherwise
6
+ * only reachable on hardware: virtual time, time-zone travel, permission
7
+ * transitions, process death, boot-restoration gaps, and platform truncation.
8
+ */
9
+
10
+ import type {
11
+ NotificationEngineAdapter,
12
+ DeliveryEvent,
13
+ EngineCapabilities,
14
+ EngineScheduleRequest,
15
+ RepeatStop,
16
+ ToneDeclarationOutcome,
17
+ } from '../engine/adapter';
18
+ import type { PendingEntry } from '../contract/notification';
19
+ import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
20
+ import type { ResolvedTone, ChannelGroup } from '../contract/channel';
21
+ import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
22
+ import { isRepeatStopId } from '../repeat/marker';
23
+
24
+ export interface FakeEngineOptions {
25
+ readonly pendingCeiling?: number;
26
+ readonly supportsExactAlarms?: boolean;
27
+ readonly supportsDoNotDisturbBypass?: boolean;
28
+ readonly supportsRepeatingAlert?: boolean;
29
+ readonly backgroundWindowMs?: number;
30
+ readonly initialPermissions?: Partial<Record<PermissionKind, PermissionState>>;
31
+ readonly timeZone?: string;
32
+ readonly now?: number;
33
+ }
34
+
35
+ /** Every platform operation the engine was asked to perform, in order. */
36
+ export interface RecordedOperation {
37
+ readonly kind:
38
+ | 'schedule'
39
+ | 'cancel'
40
+ | 'cancelAll'
41
+ | 'declareTone'
42
+ | 'retireTone'
43
+ | 'silence';
44
+ readonly id?: string;
45
+ readonly at: number;
46
+ }
47
+
48
+ /** A notification the fake has delivered to the tray. */
49
+ export interface DisplayedNotification {
50
+ readonly id: string;
51
+ readonly tone: string;
52
+ /** Whether its alert is still sounding — the property repeat expiry is about. */
53
+ readonly alerting: boolean;
54
+ readonly deliveredAt: number;
55
+ }
56
+
57
+ export interface FakeControls {
58
+ /** Operations recorded since the last reset. The idempotence assertion reads this. */
59
+ readonly operations: readonly RecordedOperation[];
60
+ clearOperations(): void;
61
+
62
+ /** Virtual clock, so tests never sleep. */
63
+ now(): number;
64
+ advanceTime(ms: number): void;
65
+ setTime(epochMs: number): void;
66
+
67
+ /** Simulates the user travelling. */
68
+ timeZone(): string;
69
+ setTimeZone(zone: string): void;
70
+
71
+ setPermission(kind: PermissionKind, state: PermissionState): void;
72
+ setRestrictions(report: Partial<RestrictionReport>): void;
73
+
74
+ /**
75
+ * Drops entries the platform would have silently discarded past its ceiling,
76
+ * so truncation behavior is testable without an iOS device.
77
+ */
78
+ applyPlatformCeiling(): readonly string[];
79
+
80
+ /**
81
+ * Simulates the process dying: in-memory listeners are lost while scheduled
82
+ * notifications survive, which is exactly what happens on a real device.
83
+ */
84
+ simulateProcessDeath(): void;
85
+
86
+ /** Simulates a manufacturer losing notifications across a reboot. */
87
+ simulateBootGap(ids: readonly string[]): void;
88
+
89
+ /** Queues an interaction that arrived before any handler existed. */
90
+ emitInteraction(event: InteractionEvent): void;
91
+ queueInitialInteraction(event: InteractionEvent): void;
92
+
93
+ /** The labels a declared channel currently carries, so a label refresh is assertable. */
94
+ channelLabels(channelId: string): { name: string; description?: string } | undefined;
95
+
96
+ /** Whether a delivered notification's alert is still sounding. */
97
+ isAlerting(id: string): boolean;
98
+
99
+ /** The tray. Distinct from the pending set: expiry is about what is *displayed*. */
100
+ displayed(): readonly DisplayedNotification[];
101
+
102
+ /**
103
+ * Fires every trigger whose instant has passed, in order, on the virtual clock.
104
+ *
105
+ * The fake modelled time, process death, and boot gaps but never modelled
106
+ * delivery — and expiry is a delivery-ordering property. Without this, proving
107
+ * that an alert stops on its own would need a real device or a real wait.
108
+ */
109
+ deliverDue(): Promise<readonly string[]>;
110
+
111
+ /** Simulates the user acting on a delivered notification: the alert stops, the entry goes. */
112
+ handleDisplayed(id: string): void;
113
+ }
114
+
115
+ export type FakeEngine = NotificationEngineAdapter & FakeControls;
116
+
117
+ export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
118
+ const scheduled = new Map<string, PendingEntry>();
119
+ const channels = new Map<string, { name: string; description?: string }>();
120
+ const loops = new Map<string, boolean>();
121
+ const stops = new Map<string, RepeatStop>();
122
+ const tray = new Map<string, { tone: string; alerting: boolean; deliveredAt: number }>();
123
+ let operations: RecordedOperation[] = [];
124
+ let listeners: ((event: InteractionEvent) => void)[] = [];
125
+ let deliveryListeners: ((event: DeliveryEvent) => void)[] = [];
126
+ let backgroundHandler: ((event: DeliveryEvent) => Promise<void>) | undefined;
127
+ let initialInteraction: InteractionEvent | undefined;
128
+
129
+ let currentTime = options.now ?? Date.UTC(2026, 0, 1, 12, 0, 0);
130
+ let currentZone = options.timeZone ?? 'UTC';
131
+
132
+ const permissions: Record<PermissionKind, PermissionState> = {
133
+ display: options.initialPermissions?.display ?? 'notAsked',
134
+ exactAlarm: options.initialPermissions?.exactAlarm ?? 'notAsked',
135
+ };
136
+
137
+ let restrictions: RestrictionReport = {
138
+ batteryOptimization: 'unrestricted',
139
+ autoStart: 'unrestricted',
140
+ readAt: currentTime,
141
+ };
142
+
143
+ const capabilities: EngineCapabilities = {
144
+ pendingCeiling: options.pendingCeiling ?? 64,
145
+ supportsExactAlarms: options.supportsExactAlarms ?? true,
146
+ supportsChannels: true,
147
+ supportsTextInputActions: true,
148
+ supportsDoNotDisturbBypass: options.supportsDoNotDisturbBypass ?? true,
149
+ supportsRepeatingAlert: options.supportsRepeatingAlert ?? true,
150
+ backgroundWindowMs: options.backgroundWindowMs ?? 30_000,
151
+ };
152
+
153
+ const record = (kind: RecordedOperation['kind'], id?: string): void => {
154
+ operations.push({ kind, id, at: currentTime });
155
+ };
156
+
157
+ const toPendingEntry = (request: EngineScheduleRequest): PendingEntry => ({
158
+ id: request.request.id,
159
+ resolvedAt: request.resolvedAt,
160
+ anchoring: request.request.moment.anchoring ?? 'wallClock',
161
+ anchoringGeneration:
162
+ (request.request.moment.anchoring ?? 'wallClock') === 'wallClock' ? currentZone : undefined,
163
+ content: request.request.content,
164
+ tone: request.request.tone,
165
+ routing: request.request.routing,
166
+ actions: request.request.actions,
167
+ ownerTag: request.request.ownerTag,
168
+ groupTag: request.request.groupTag,
169
+ seriesId: request.seriesId,
170
+ mayBeDelayed: request.approximate,
171
+ pushCreated: request.pushCreated ?? false,
172
+ });
173
+
174
+ return {
175
+ name: 'fake',
176
+
177
+ async getCapabilities() {
178
+ return capabilities;
179
+ },
180
+
181
+ async schedule(request) {
182
+ record('schedule', request.request.id);
183
+ scheduled.set(request.request.id, toPendingEntry(request));
184
+ loops.set(request.request.id, request.tone.android.loopSound);
185
+ if (request.repeatStop !== undefined) {
186
+ stops.set(request.request.id, request.repeatStop);
187
+ }
188
+ },
189
+
190
+ async cancel(id) {
191
+ record('cancel', id);
192
+ scheduled.delete(id);
193
+ loops.delete(id);
194
+ stops.delete(id);
195
+ tray.delete(id);
196
+ },
197
+
198
+ async cancelMany(ids) {
199
+ for (const id of ids) {
200
+ record('cancel', id);
201
+ scheduled.delete(id);
202
+ loops.delete(id);
203
+ stops.delete(id);
204
+ tray.delete(id);
205
+ }
206
+ },
207
+
208
+ async cancelAll() {
209
+ record('cancelAll');
210
+ scheduled.clear();
211
+ loops.clear();
212
+ stops.clear();
213
+ tray.clear();
214
+ },
215
+
216
+ async getPending() {
217
+ // Stop markers are package-owned artifacts, not user notifications. A
218
+ // consumer diffing the device against its own intent must never see one.
219
+ return [...scheduled.values()]
220
+ .filter(entry => !isRepeatStopId(entry.id))
221
+ .sort((a, b) => a.resolvedAt - b.resolvedAt);
222
+ },
223
+
224
+ async declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome> {
225
+ record('declareTone', tone.android.channelId);
226
+ const existing = channels.get(tone.android.channelId);
227
+
228
+ if (existing === undefined) {
229
+ channels.set(tone.android.channelId, {
230
+ name: tone.android.name,
231
+ description: tone.android.description,
232
+ });
233
+ return 'created';
234
+ }
235
+
236
+ // Android freezes importance, sound, vibration and DND once a channel
237
+ // exists, but re-declaring updates the name and description. The fake
238
+ // mirrors exactly that: labels move, everything else is ignored — which
239
+ // is what makes a label correction free for the user.
240
+ if (
241
+ existing.name === tone.android.name &&
242
+ existing.description === tone.android.description
243
+ ) {
244
+ return 'unchanged';
245
+ }
246
+
247
+ channels.set(tone.android.channelId, {
248
+ name: tone.android.name,
249
+ description: tone.android.description,
250
+ });
251
+ return 'labelsRefreshed';
252
+ },
253
+
254
+ async retireTone(channelId: string) {
255
+ record('retireTone', channelId);
256
+ return channels.delete(channelId);
257
+ },
258
+
259
+ async declareGroups(_groups: readonly ChannelGroup[]) {
260
+ // Groups are presentational; the fake accepts them without recording.
261
+ },
262
+
263
+ async listDeclaredChannels() {
264
+ return [...channels.keys()];
265
+ },
266
+
267
+ async getPermission(kind) {
268
+ return permissions[kind];
269
+ },
270
+
271
+ async requestPermission(kind) {
272
+ if (permissions[kind] === 'notAsked') {
273
+ permissions[kind] = 'granted';
274
+ }
275
+ return permissions[kind];
276
+ },
277
+
278
+ async openSettings(_kind) {
279
+ // No-op: the fake cannot open a settings screen, and pretending it did would be a lie.
280
+ },
281
+
282
+ async getRestrictions() {
283
+ return { ...restrictions, readAt: currentTime };
284
+ },
285
+
286
+ subscribeToInteractions(handler): Unsubscribe {
287
+ listeners.push(handler);
288
+ return () => {
289
+ listeners = listeners.filter(l => l !== handler);
290
+ };
291
+ },
292
+
293
+ async takeInitialInteraction() {
294
+ const event = initialInteraction;
295
+ initialInteraction = undefined;
296
+ return event;
297
+ },
298
+
299
+ async silenceDelivered(id: string) {
300
+ record('silence', id);
301
+ const entry = tray.get(id);
302
+ // Absent is the ordinary case, not an error: the user handled it before
303
+ // the window ran out.
304
+ if (entry === undefined) return false;
305
+
306
+ // Replaced in place, never removed. A notification that vanished is a
307
+ // worse outcome than one that never sounded.
308
+ tray.set(id, { ...entry, alerting: false });
309
+ return true;
310
+ },
311
+
312
+ async listRepeatStops() {
313
+ return [...stops.values()].filter(stop => scheduled.has(stop.markerId));
314
+ },
315
+
316
+ subscribeToDeliveries(handler): Unsubscribe {
317
+ deliveryListeners.push(handler);
318
+ return () => {
319
+ deliveryListeners = deliveryListeners.filter(l => l !== handler);
320
+ };
321
+ },
322
+
323
+ registerBackgroundDeliveryHandler(handler) {
324
+ backgroundHandler = handler;
325
+ },
326
+
327
+ hasBackgroundDeliveryHandler() {
328
+ return backgroundHandler !== undefined;
329
+ },
330
+
331
+ // --- FakeControls ------------------------------------------------------
332
+
333
+ get operations() {
334
+ return operations;
335
+ },
336
+
337
+ clearOperations() {
338
+ operations = [];
339
+ },
340
+
341
+ now() {
342
+ return currentTime;
343
+ },
344
+
345
+ advanceTime(ms) {
346
+ currentTime += ms;
347
+ },
348
+
349
+ setTime(epochMs) {
350
+ currentTime = epochMs;
351
+ },
352
+
353
+ timeZone() {
354
+ return currentZone;
355
+ },
356
+
357
+ setTimeZone(zone) {
358
+ currentZone = zone;
359
+ },
360
+
361
+ setPermission(kind, state) {
362
+ permissions[kind] = state;
363
+ },
364
+
365
+ setRestrictions(report) {
366
+ restrictions = { ...restrictions, ...report };
367
+ },
368
+
369
+ applyPlatformCeiling() {
370
+ const ordered = [...scheduled.values()].sort((a, b) => a.resolvedAt - b.resolvedAt);
371
+ const dropped = ordered.slice(capabilities.pendingCeiling);
372
+ for (const entry of dropped) {
373
+ scheduled.delete(entry.id);
374
+ }
375
+ return dropped.map(entry => entry.id);
376
+ },
377
+
378
+ simulateProcessDeath() {
379
+ // Scheduled notifications live in the OS and survive; in-process
380
+ // listeners do not. The background handler does survive, because on a
381
+ // real device it is registered at module scope and the platform starts
382
+ // a fresh process to run it — which is the entire reason repeat expiry
383
+ // depends on it rather than on a foreground subscription.
384
+ listeners = [];
385
+ deliveryListeners = [];
386
+ operations = [];
387
+ },
388
+
389
+ simulateBootGap(ids) {
390
+ for (const id of ids) {
391
+ scheduled.delete(id);
392
+ }
393
+ },
394
+
395
+ emitInteraction(event) {
396
+ for (const listener of listeners) {
397
+ listener(event);
398
+ }
399
+ },
400
+
401
+ queueInitialInteraction(event) {
402
+ initialInteraction = event;
403
+ },
404
+
405
+ channelLabels(channelId) {
406
+ return channels.get(channelId);
407
+ },
408
+
409
+ isAlerting(id) {
410
+ return tray.get(id)?.alerting ?? false;
411
+ },
412
+
413
+ displayed() {
414
+ return [...tray.entries()].map(([id, entry]) => ({
415
+ id,
416
+ tone: entry.tone,
417
+ alerting: entry.alerting,
418
+ deliveredAt: entry.deliveredAt,
419
+ }));
420
+ },
421
+
422
+ async deliverDue() {
423
+ const due = [...scheduled.values()]
424
+ .filter(entry => entry.resolvedAt <= currentTime)
425
+ .sort((a, b) => a.resolvedAt - b.resolvedAt);
426
+
427
+ const delivered: string[] = [];
428
+
429
+ for (const entry of due) {
430
+ scheduled.delete(entry.id);
431
+ const stop = stops.get(entry.id);
432
+
433
+ if (stop === undefined) {
434
+ // A real notification: it lands in the tray and, if its tone
435
+ // repeats, keeps sounding until something stops it.
436
+ tray.set(entry.id, {
437
+ tone: entry.tone,
438
+ alerting: loops.get(entry.id) ?? false,
439
+ deliveredAt: currentTime,
440
+ });
441
+ }
442
+
443
+ const event: DeliveryEvent = {
444
+ notificationId: entry.id,
445
+ occurredAt: currentTime,
446
+ repeatStop: stop,
447
+ };
448
+
449
+ // Foreground listeners when the process is alive, the background
450
+ // handler when it is not — the same fork a real device makes.
451
+ if (deliveryListeners.length > 0) {
452
+ for (const listener of deliveryListeners) {
453
+ listener(event);
454
+ }
455
+ } else if (backgroundHandler !== undefined) {
456
+ await backgroundHandler(event);
457
+ }
458
+
459
+ delivered.push(entry.id);
460
+ }
461
+
462
+ return delivered;
463
+ },
464
+
465
+ handleDisplayed(id) {
466
+ tray.delete(id);
467
+ },
468
+ };
469
+ }