@chipmobilesdk/rn-notification 0.3.0 → 0.4.1

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