@chipmobilesdk/rn-notification 0.4.0 → 0.5.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 (75) hide show
  1. package/CHANGELOG.md +257 -166
  2. package/README.md +420 -410
  3. package/package.json +61 -75
  4. package/skills/sdk-rn-notification/SKILL.md +172 -173
  5. package/src/channel/migrate.ts +285 -289
  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 +142 -134
  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 -129
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +81 -72
  19. package/src/diagnostics/redact.ts +55 -72
  20. package/src/diagnostics/snapshot.ts +100 -64
  21. package/src/engine/adapter.ts +172 -172
  22. package/src/engine/codec.ts +89 -89
  23. package/src/engine/notify-kit-engine.ts +715 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +247 -236
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -154
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -101
  30. package/src/maintenance/toggle.ts +66 -64
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -64
  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 +150 -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 -180
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -319
  49. package/src/recurrence/index.ts +270 -259
  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 +294 -267
  55. package/src/schedule/index.ts +174 -181
  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 -55
  61. package/src/state/caps.ts +71 -71
  62. package/src/state/dedup-ledger.ts +35 -35
  63. package/src/state/interaction-buffer.ts +101 -73
  64. package/src/state/mmkv-store.ts +92 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +270 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +43 -40
  70. package/src/testing/conformance/index.ts +258 -248
  71. package/src/testing/fake-engine.ts +462 -468
  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,707 +1,715 @@
1
- /**
2
- * The `react-native-notify-kit` engine implementation.
3
- *
4
- * Every import of the engine is confined to this file. Nothing above it knows
5
- * which engine is in use — which is what allowed the original Notifee choice to
6
- * be swapped for its maintained fork without touching a requirement, a test, or
7
- * a line of consumer code.
8
- *
9
- * The module is required lazily so the package loads in Jest and in any
10
- * environment without the native module. An absent engine is reported through
11
- * the normal diagnostic path rather than crashing at import time.
12
- */
13
-
14
- import { Platform } from 'react-native';
15
- import type {
16
- NotificationEngineAdapter,
17
- DeliveryEvent,
18
- EngineCapabilities,
19
- EngineScheduleRequest,
20
- RepeatStop,
21
- ToneDeclarationOutcome,
22
- } from './adapter';
23
- import { isRepeatStopId } from '../repeat/marker';
24
- import { packData, unpackData } from './codec';
25
- import type { PendingEntry } from '../contract/notification';
26
- import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
27
- import type { ResolvedTone, ChannelGroup } from '../contract/channel';
28
- import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
29
- import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
30
-
31
- /**
32
- * iOS keeps only the 64 soonest pending notifications and silently discards the
33
- * rest. This number is the entire reason truncation must be computed and
34
- * reported by the package rather than left to the platform.
35
- */
36
- const IOS_PENDING_CEILING = 64;
37
-
38
- /** Android has no comparable hard ceiling; this bound keeps behavior predictable. */
39
- const ANDROID_PENDING_BUDGET = 500;
40
-
41
- /** Both platforms bound background execution for a silent payload or background action. */
42
- const BACKGROUND_WINDOW_MS = 30_000;
43
-
44
- /**
45
- * The engine's own enum values, mirrored as literals.
46
- *
47
- * These are **numeric** enums in the engine's type definitions, and its native
48
- * validator rejects the string names — `importance: 'default'` fails with
49
- * "expected a valid Importance". Mirroring them here rather than importing the
50
- * enums keeps the engine out of the module graph, which is what lets the
51
- * package load without the native module present.
52
- *
53
- * The package's own `ResolvedTone` deliberately stays platform-neutral strings;
54
- * translating to the engine's representation is this adapter's job and nobody
55
- * else's.
56
- */
57
- const ANDROID_IMPORTANCE = {
58
- none: 0,
59
- min: 1,
60
- low: 2,
61
- default: 3,
62
- high: 4,
63
- } as const;
64
-
65
- /** `TriggerType.TIMESTAMP`. */
66
- const TRIGGER_TYPE_TIMESTAMP = 0;
67
-
68
- /** `AlarmType.SET_EXACT_AND_ALLOW_WHILE_IDLE` — exact delivery, survives Doze. */
69
- const ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE = 3;
70
-
71
- /** `AlarmType.SET_AND_ALLOW_WHILE_IDLE` — the approximate degradation of the above. */
72
- const ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE = 1;
73
-
74
- /**
75
- * `EventType` values that represent a real user interaction.
76
- *
77
- * The engine emits far more than interactions on this channel — `DELIVERED`,
78
- * `TRIGGER_NOTIFICATION_CREATED`, `CHANNEL_BLOCKED` and others all arrive here.
79
- * Treating anything that is not a dismissal as a press would report a
80
- * notification *arriving* while the app is open as the user having tapped it,
81
- * and the interaction path cancels on tap — so the notification would vanish
82
- * from the tray in front of the user, unread.
83
- */
84
- const EVENT_TYPE_DISMISSED = 0;
85
- const EVENT_TYPE_PRESS = 1;
86
- const EVENT_TYPE_ACTION_PRESS = 2;
87
-
88
- /** `EventType.DELIVERED` — an arrival, which is emphatically not an interaction. */
89
- const EVENT_TYPE_DELIVERED = 3;
90
-
91
- /**
92
- * `AndroidCategory.ALARM`, the engine's marking for a time-sensitive alert.
93
- *
94
- * A per-notification property, not channel configuration — which is why
95
- * declaring `timeSensitive` on an existing tone never obliges a version bump.
96
- */
97
- const ANDROID_CATEGORY_ALARM = 'alarm';
98
-
99
- /**
100
- * The channel stop markers are posted on.
101
- *
102
- * A marker exists to be delivered and immediately removed, so it is given the
103
- * quietest configuration the platform offers: minimum importance, no sound, no
104
- * vibration. Its version is fixed because nothing about it is consumer-declared,
105
- * so it never migrates.
106
- */
107
- const MARKER_CHANNEL_ID = 'cms.internal.stop.v1';
108
-
109
- /**
110
- * Whether this *process* has a background delivery handler.
111
- *
112
- * Module scope, not adapter instance state. The engine registers one headless
113
- * task per process, so registering through any adapter registers it for all of
114
- * them. Keeping the flag per instance made an app that had wired the handler up
115
- * correctly report that it had not — which is worse than not reporting at all,
116
- * because the warning tells the consumer to fix something that is not broken.
117
- */
118
- let backgroundHandlerRegistered = false;
119
-
120
- /** Minimal shape used from the engine, so its full surface never leaks into typing. */
121
- interface NotifyKitModule {
122
- createChannel(channel: Record<string, unknown>): Promise<string>;
123
- deleteChannel(channelId: string): Promise<void>;
124
- /**
125
- * Labels come back with the id. That is what makes a label refresh decidable
126
- * against the device rather than against a ledger of our own — and a ledger
127
- * drifts after a restore, a reinstall, or a change the user made themselves.
128
- */
129
- getChannels(): Promise<{ id: string; name?: string; description?: string }[]>;
130
- createChannelGroup(group: Record<string, unknown>): Promise<string>;
131
- createTriggerNotification(
132
- notification: Record<string, unknown>,
133
- trigger: Record<string, unknown>,
134
- ): Promise<string>;
135
- displayNotification(notification: Record<string, unknown>): Promise<string>;
136
- getDisplayedNotifications(): Promise<Record<string, unknown>[]>;
137
- cancelNotification(id: string): Promise<void>;
138
- cancelAllNotifications(): Promise<void>;
139
- getTriggerNotifications(): Promise<Record<string, unknown>[]>;
140
- onBackgroundEvent(handler: (event: Record<string, unknown>) => Promise<void>): void;
141
- requestPermission(options?: Record<string, unknown>): Promise<Record<string, unknown>>;
142
- getNotificationSettings(): Promise<Record<string, unknown>>;
143
- openNotificationSettings(): Promise<void>;
144
- openAlarmPermissionSettings(): Promise<void>;
145
- isBatteryOptimizationEnabled(): Promise<boolean>;
146
- onForegroundEvent(handler: (event: Record<string, unknown>) => void): () => void;
147
- getInitialNotification(): Promise<Record<string, unknown> | null>;
148
- }
149
-
150
- /**
151
- * Drops keys whose value is `undefined`.
152
- *
153
- * The engine validates its arguments by key presence, not by value: passing
154
- * `{ groupId: undefined }` fails with "expected a string value", while omitting
155
- * the key entirely is accepted. JavaScript treats the two as interchangeable
156
- * almost everywhere, which is exactly why this is easy to get wrong — and why
157
- * it only surfaces on a real device, where the native validator runs.
158
- */
159
- function compact<T extends Record<string, unknown>>(value: T): Partial<T> {
160
- const output: Record<string, unknown> = {};
161
- for (const [key, entry] of Object.entries(value)) {
162
- if (entry !== undefined) output[key] = entry;
163
- }
164
- return output as Partial<T>;
165
- }
166
-
167
- function loadEngine(): NotifyKitModule {
168
- try {
169
- const module = require('react-native-notify-kit') as { default: NotifyKitModule };
170
- return module.default;
171
- } catch (error) {
172
- throw new NotificationError(
173
- DIAGNOSTIC_CODES.PUSH_UNAVAILABLE,
174
- 'react-native-notify-kit is not installed or its native module is not linked. Install it as a peer dependency and rebuild the app.',
175
- { reason: error instanceof Error ? error.name : 'unknown' },
176
- );
177
- }
178
- }
179
-
180
- /**
181
- * Maps engine authorization values onto our six-state model.
182
- *
183
- * `notAsked` and `denied` must stay distinguishable — being able to read state
184
- * without prompting is worthless if the two collapse. `provisional` is its own
185
- * state on both edges, never folded into granted or denied.
186
- */
187
- function toPermissionState(raw: unknown): PermissionState {
188
- switch (raw) {
189
- case 1:
190
- case 'authorized':
191
- return 'granted';
192
- case 2:
193
- case 'provisional':
194
- return 'provisional';
195
- case 0:
196
- case 'denied':
197
- return 'denied';
198
- case -1:
199
- case 'notDetermined':
200
- return 'notAsked';
201
- default:
202
- return 'notAsked';
203
- }
204
- }
205
-
206
- export interface NotifyKitEngineOptions {
207
- /** Injected in tests; production resolves the real module. */
208
- readonly module?: NotifyKitModule;
209
- readonly now?: () => number;
210
- }
211
-
212
- export function createNotifyKitEngine(
213
- options: NotifyKitEngineOptions = {},
214
- ): NotificationEngineAdapter {
215
- let cached: NotifyKitModule | undefined = options.module;
216
- const engine = (): NotifyKitModule => (cached ??= loadEngine());
217
- const now = options.now ?? (() => Date.now());
218
-
219
- /** Reads a stop marker back out of a pending entry's data bag, if it is one. */
220
- const unpackRepeatStop = (
221
- id: string,
222
- data: Record<string, string>,
223
- ): RepeatStop | undefined => {
224
- if (!isRepeatStopId(id)) return undefined;
225
- const targetId = data.__cms_stop_target;
226
- const dueAt = Number.parseInt(data.__cms_stop_due ?? '', 10);
227
- if (typeof targetId !== 'string' || !Number.isFinite(dueAt)) return undefined;
228
- return { markerId: id, targetId, dueAt };
229
- };
230
-
231
- const unpackEntry = (raw: Record<string, unknown>): PendingEntry | undefined => {
232
- const notification = (raw.notification ?? {}) as Record<string, unknown>;
233
- const trigger = (raw.trigger ?? {}) as Record<string, unknown>;
234
- const data = (notification.data ?? {}) as Record<string, string>;
235
- const id = notification.id;
236
-
237
- if (typeof id !== 'string') return undefined;
238
-
239
- return {
240
- id,
241
- resolvedAt: typeof trigger.timestamp === 'number' ? trigger.timestamp : now(),
242
- content: {
243
- title: typeof notification.title === 'string' ? notification.title : '',
244
- body: typeof notification.body === 'string' ? notification.body : '',
245
- subtitle: typeof notification.subtitle === 'string' ? notification.subtitle : undefined,
246
- },
247
- ...unpackData(data),
248
- };
249
- };
250
-
251
- const toInteraction = (raw: Record<string, unknown>): InteractionEvent | undefined => {
252
- const eventType = raw.type;
253
-
254
- // Only a dismissal or a press is an interaction. Everything else the
255
- // engine emits on this channel — delivery, trigger creation, channel
256
- // blocking — is not, and must not be reported as one.
257
- if (
258
- eventType !== EVENT_TYPE_DISMISSED &&
259
- eventType !== EVENT_TYPE_PRESS &&
260
- eventType !== EVENT_TYPE_ACTION_PRESS
261
- ) {
262
- return undefined;
263
- }
264
-
265
- const detail = (raw.detail ?? {}) as Record<string, unknown>;
266
- const notification = (detail.notification ?? {}) as Record<string, unknown>;
267
- const pressAction = (detail.pressAction ?? {}) as Record<string, unknown>;
268
- const id = notification.id;
269
-
270
- if (typeof id !== 'string') return undefined;
271
-
272
- const data = (notification.data ?? {}) as Record<string, string>;
273
- let routing: Record<string, unknown> | undefined;
274
- if (typeof data.__cms_routing === 'string') {
275
- try {
276
- routing = JSON.parse(data.__cms_routing) as Record<string, unknown>;
277
- } catch {
278
- routing = undefined;
279
- }
280
- }
281
-
282
- const actionId = typeof pressAction.id === 'string' ? pressAction.id : undefined;
283
- // `default` is the engine's own press-action id for tapping the body, so
284
- // it is a tap rather than a named action.
285
- const isNamedAction = actionId !== undefined && actionId !== 'default';
286
- let kind: InteractionEvent['kind'];
287
- if (eventType === EVENT_TYPE_DISMISSED) {
288
- kind = 'dismiss';
289
- } else if (isNamedAction) {
290
- kind = 'action';
291
- } else {
292
- kind = 'tap';
293
- }
294
-
295
- return {
296
- kind,
297
- notificationId: id,
298
- actionId: actionId !== 'default' ? actionId : undefined,
299
- inputText: typeof detail.input === 'string' ? detail.input : undefined,
300
- routing,
301
- ownerTag: data.__cms_owner,
302
- occurredAt: now(),
303
- appState: 'foreground',
304
- wasBuffered: false,
305
- };
306
- };
307
-
308
- /**
309
- * Creates the marker channel the first time one is needed.
310
- *
311
- * Lazily rather than at initialization, so an app that never declares a
312
- * repeating tone never grows an extra entry in the user's notification
313
- * settings for a feature it does not use.
314
- */
315
- let markerChannelReady = false;
316
- const ensureMarkerChannel = async (): Promise<void> => {
317
- if (Platform.OS !== 'android' || markerChannelReady) return;
318
-
319
- await engine().createChannel({
320
- id: MARKER_CHANNEL_ID,
321
- name: 'Background maintenance',
322
- description: 'Used briefly to end a repeating alert. Notifications here are removed immediately.',
323
- importance: ANDROID_IMPORTANCE.min,
324
- vibration: false,
325
- badge: false,
326
- });
327
- markerChannelReady = true;
328
- };
329
-
330
- let deliveryListeners: ((event: DeliveryEvent) => void)[] = [];
331
-
332
- /** A delivery, or undefined for anything that is not one. */
333
- const toDelivery = (raw: Record<string, unknown>): DeliveryEvent | undefined => {
334
- if (raw.type !== EVENT_TYPE_DELIVERED) return undefined;
335
-
336
- const detail = (raw.detail ?? {}) as Record<string, unknown>;
337
- const notification = (detail.notification ?? {}) as Record<string, unknown>;
338
- const id = notification.id;
339
- if (typeof id !== 'string') return undefined;
340
-
341
- const data = (notification.data ?? {}) as Record<string, string>;
342
- return {
343
- notificationId: id,
344
- occurredAt: now(),
345
- repeatStop: unpackRepeatStop(id, data),
346
- };
347
- };
348
-
349
- return {
350
- name: 'react-native-notify-kit',
351
-
352
- async getCapabilities(): Promise<EngineCapabilities> {
353
- return {
354
- pendingCeiling: Platform.OS === 'ios' ? IOS_PENDING_CEILING : ANDROID_PENDING_BUDGET,
355
- supportsExactAlarms: Platform.OS === 'android',
356
- supportsChannels: Platform.OS === 'android',
357
- supportsTextInputActions: true,
358
- supportsDoNotDisturbBypass: Platform.OS === 'android',
359
- // iOS exposes no looping-sound API. The one alert level that
360
- // overrides the mute switch is entitlement-gated by Apple, and does
361
- // not loop either — so there is nothing to partially support, and a
362
- // partial emulation would be worse than an honest refusal.
363
- supportsRepeatingAlert: Platform.OS === 'android',
364
- backgroundWindowMs: BACKGROUND_WINDOW_MS,
365
- };
366
- },
367
-
368
- async schedule(request: EngineScheduleRequest): Promise<void> {
369
- const actions = request.request.actions?.map(action =>
370
- compact({
371
- title: action.title,
372
- pressAction: compact({
373
- id: action.id,
374
- launchActivity: action.mode === 'foreground' ? 'default' : undefined,
375
- }),
376
- input: action.input === undefined ? undefined : compact({ placeholder: action.input.placeholder }),
377
- }),
378
- );
379
-
380
- const isMarker = request.repeatStop !== undefined;
381
- if (isMarker) {
382
- await ensureMarkerChannel();
383
- }
384
-
385
- await engine().createTriggerNotification(
386
- compact({
387
- id: request.request.id,
388
- title: request.request.content.title,
389
- body: request.request.content.body,
390
- subtitle: request.request.content.subtitle,
391
- data: packData(request),
392
- android: compact({
393
- // A marker gets the quietest channel the package owns rather than
394
- // the consumer's tone. It exists to be delivered and removed, and
395
- // borrowing the alarm's own channel would make it alarm too.
396
- channelId: isMarker ? MARKER_CHANNEL_ID : request.tone.android.channelId,
397
- // Numeric enum, not the string name — see ANDROID_IMPORTANCE.
398
- importance: isMarker
399
- ? ANDROID_IMPORTANCE.min
400
- : ANDROID_IMPORTANCE[request.tone.android.importance],
401
- sound: isMarker ? undefined : request.tone.android.sound,
402
- groupId: isMarker ? undefined : request.tone.android.groupId,
403
- // Per-notification, not channel configuration. That is what lets
404
- // a consumer add a repeating alert to an existing tone without
405
- // bumping its version and costing the user their settings.
406
- loopSound: !isMarker && request.tone.android.loopSound ? true : undefined,
407
- category:
408
- !isMarker && request.tone.android.alarmCategory
409
- ? ANDROID_CATEGORY_ALARM
410
- : undefined,
411
- actions: isMarker ? undefined : actions,
412
- }),
413
- ios: compact({
414
- categoryId: request.tone.ios.categoryId,
415
- // This one genuinely is a string union in the engine's types.
416
- interruptionLevel: request.tone.ios.interruptionLevel,
417
- sound: request.tone.ios.sound,
418
- }),
419
- }),
420
- {
421
- type: TRIGGER_TYPE_TIMESTAMP,
422
- timestamp: request.resolvedAt,
423
- // Approximate mode is a degradation, not a failure: the notification
424
- // is still scheduled and the caller is told it may arrive late.
425
- alarmManager: {
426
- allowWhileIdle: true,
427
- type: request.approximate
428
- ? ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE
429
- : ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE,
430
- },
431
- },
432
- );
433
- },
434
-
435
- async cancel(id: string): Promise<void> {
436
- await engine().cancelNotification(id);
437
- },
438
-
439
- async cancelMany(ids: readonly string[]): Promise<void> {
440
- // Sequential rather than parallel: the engine bridges each call to
441
- // native, and a burst of concurrent cancels is where duplicate-delivery
442
- // races have historically appeared.
443
- for (const id of ids) {
444
- await engine().cancelNotification(id);
445
- }
446
- },
447
-
448
- async cancelAll(): Promise<void> {
449
- await engine().cancelAllNotifications();
450
- },
451
-
452
- async getPending(): Promise<readonly PendingEntry[]> {
453
- const raw = await engine().getTriggerNotifications();
454
- return raw
455
- .map(unpackEntry)
456
- .filter((entry): entry is PendingEntry => entry !== undefined)
457
- // Stop markers are package-owned artifacts. A consumer diffing the
458
- // device against its own intent would otherwise see a notification it
459
- // never scheduled and, quite reasonably, cancel it.
460
- .filter(entry => !isRepeatStopId(entry.id))
461
- .sort((a, b) => a.resolvedAt - b.resolvedAt);
462
- },
463
-
464
- async declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome> {
465
- if (Platform.OS !== 'android') {
466
- // iOS categories are declared at request time rather than up front.
467
- return 'unchanged';
468
- }
469
-
470
- const existing = await engine().getChannels();
471
- const held = existing.find(channel => channel.id === tone.android.channelId);
472
-
473
- const payload = compact({
474
- id: tone.android.channelId,
475
- // The consumer's declared label, not the tone identifier. Passing the
476
- // identifier here is what put a technical string in front of users on
477
- // the one screen these labels exist to serve.
478
- name: tone.android.name,
479
- description: tone.android.description,
480
- importance: ANDROID_IMPORTANCE[tone.android.importance],
481
- sound: tone.android.sound,
482
- vibration: tone.android.vibration,
483
- badge: tone.android.badge,
484
- bypassDnd: tone.android.bypassDnd,
485
- groupId: tone.android.groupId,
486
- });
487
-
488
- if (held === undefined) {
489
- await engine().createChannel(payload);
490
- return 'created';
491
- }
492
-
493
- if (held.name === tone.android.name && held.description === tone.android.description) {
494
- // Nothing to say to the platform. Skipping the call is what keeps an
495
- // unchanged configuration genuinely free, which reconciliation
496
- // idempotence depends on.
497
- return 'unchanged';
498
- }
499
-
500
- // Android accepts a re-declaration of an existing channel and applies
501
- // only the name and description, ignoring importance, sound, vibration
502
- // and DND — because those belong to the user once the channel exists.
503
- // That refusal is the guarantee: we do not have to protect the user's
504
- // settings here, the platform declines to overwrite them.
505
- await engine().createChannel(payload);
506
- return 'labelsRefreshed';
507
- },
508
-
509
- async retireTone(channelId: string): Promise<boolean> {
510
- if (Platform.OS !== 'android') return false;
511
-
512
- const existing = await engine().getChannels();
513
- if (!existing.some(channel => channel.id === channelId)) return false;
514
-
515
- await engine().deleteChannel(channelId);
516
- return true;
517
- },
518
-
519
- async declareGroups(groups: readonly ChannelGroup[]): Promise<void> {
520
- if (Platform.OS !== 'android') return;
521
-
522
- for (const group of groups) {
523
- await engine().createChannelGroup(
524
- compact({ id: group.id, name: group.name, description: group.description }),
525
- );
526
- }
527
- },
528
-
529
- async listDeclaredChannels(): Promise<readonly string[]> {
530
- if (Platform.OS !== 'android') return [];
531
- const channels = await engine().getChannels();
532
- return channels.map(channel => channel.id);
533
- },
534
-
535
- async getPermission(kind: PermissionKind): Promise<PermissionState> {
536
- const settings = await engine().getNotificationSettings();
537
-
538
- if (kind === 'display') {
539
- // Android below 13 has no runtime display permission at all.
540
- if (Platform.OS === 'android' && (Platform.Version as number) < 33) {
541
- return 'notRequired';
542
- }
543
- return toPermissionState(settings.authorizationStatus);
544
- }
545
-
546
- // Exact alarms exist only on Android 12+; elsewhere the concept does not
547
- // apply, which is "not required" rather than "denied".
548
- if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
549
- return 'notRequired';
550
- }
551
-
552
- const android = (settings.android ?? {}) as Record<string, unknown>;
553
- return android.alarm === 1 ? 'granted' : 'denied';
554
- },
555
-
556
- async requestPermission(kind: PermissionKind): Promise<PermissionState> {
557
- if (kind === 'display') {
558
- const result = await engine().requestPermission();
559
- return toPermissionState(result.authorizationStatus);
560
- }
561
-
562
- // Exact-alarm capability is granted only through system settings on the
563
- // versions that gate it — there is no in-app dialog to present.
564
- if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
565
- return 'notRequired';
566
- }
567
- await engine().openAlarmPermissionSettings();
568
- return this.getPermission('exactAlarm');
569
- },
570
-
571
- async openSettings(kind: PermissionKind): Promise<void> {
572
- if (kind === 'exactAlarm') {
573
- if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
574
- throw new NotificationError(
575
- DIAGNOSTIC_CODES.SETTINGS_UNAVAILABLE,
576
- 'No exact-alarm settings screen exists on this platform version.',
577
- { platform: Platform.OS, version: Platform.Version },
578
- );
579
- }
580
- await engine().openAlarmPermissionSettings();
581
- return;
582
- }
583
- await engine().openNotificationSettings();
584
- },
585
-
586
- async getRestrictions(): Promise<RestrictionReport> {
587
- if (Platform.OS !== 'android') {
588
- return {
589
- batteryOptimization: 'unrestricted',
590
- autoStart: 'unrestricted',
591
- readAt: now(),
592
- };
593
- }
594
-
595
- let batteryOptimization: RestrictionReport['batteryOptimization'] = 'unknown';
596
- try {
597
- batteryOptimization = (await engine().isBatteryOptimizationEnabled())
598
- ? 'restricted'
599
- : 'unrestricted';
600
- } catch {
601
- batteryOptimization = 'unknown';
602
- }
603
-
604
- return {
605
- batteryOptimization,
606
- // No standard API exposes vendor auto-start policy, and guessing would
607
- // be worse than admitting it. `unknown` is a first-class answer here.
608
- autoStart: 'unknown',
609
- readAt: now(),
610
- };
611
- },
612
-
613
- subscribeToInteractions(handler: (event: InteractionEvent) => void): Unsubscribe {
614
- return engine().onForegroundEvent(raw => {
615
- const event = toInteraction(raw);
616
- if (event !== undefined) handler(event);
617
- });
618
- },
619
-
620
- async takeInitialInteraction(): Promise<InteractionEvent | undefined> {
621
- const raw = await engine().getInitialNotification();
622
- if (raw === null) return undefined;
623
-
624
- const event = toInteraction(raw);
625
- // A cold-start tap arrives before any handler exists, so it is reported
626
- // with the state that actually produced it.
627
- return event === undefined ? undefined : { ...event, appState: 'coldStart' };
628
- },
629
-
630
- async silenceDelivered(id: string): Promise<boolean> {
631
- const displayed = await engine().getDisplayedNotifications();
632
- const match = displayed.find(entry => {
633
- const notification = (entry.notification ?? entry) as Record<string, unknown>;
634
- return notification.id === id;
635
- });
636
-
637
- // Not displayed is the ordinary outcome, not a failure: the user handled
638
- // it before the window ran out.
639
- if (match === undefined) return false;
640
-
641
- const notification = (match.notification ?? match) as Record<string, unknown>;
642
- const android = (notification.android ?? {}) as Record<string, unknown>;
643
-
644
- // Re-posted under the *same* identifier, so it lands on the same tray
645
- // slot and replaces in place. Cancelling instead would remove it, and a
646
- // notification that vanished is worse than one that never sounded — the
647
- // user comes home to a silent phone and no idea what they missed.
648
- await engine().displayNotification({
649
- ...notification,
650
- android: {
651
- ...android,
652
- loopSound: false,
653
- // Without this the replacement re-alerts once on its way to going
654
- // quiet, which is a strange thing to do to someone who has already
655
- // heard it for fifteen minutes.
656
- onlyAlertOnce: true,
657
- },
658
- });
659
- return true;
660
- },
661
-
662
- async listRepeatStops(): Promise<readonly RepeatStop[]> {
663
- const raw = await engine().getTriggerNotifications();
664
- const stops: RepeatStop[] = [];
665
-
666
- for (const entry of raw) {
667
- const notification = (entry.notification ?? {}) as Record<string, unknown>;
668
- const id = notification.id;
669
- if (typeof id !== 'string') continue;
670
-
671
- const data = (notification.data ?? {}) as Record<string, string>;
672
- const stop = unpackRepeatStop(id, data);
673
- if (stop !== undefined) stops.push(stop);
674
- }
675
-
676
- return stops;
677
- },
678
-
679
- subscribeToDeliveries(handler: (event: DeliveryEvent) => void): Unsubscribe {
680
- deliveryListeners.push(handler);
681
-
682
- const unsubscribe = engine().onForegroundEvent(raw => {
683
- const event = toDelivery(raw);
684
- if (event !== undefined) handler(event);
685
- });
686
-
687
- return () => {
688
- deliveryListeners = deliveryListeners.filter(l => l !== handler);
689
- unsubscribe();
690
- };
691
- },
692
-
693
- registerBackgroundDeliveryHandler(
694
- handler: (event: DeliveryEvent) => Promise<void>,
695
- ): void {
696
- engine().onBackgroundEvent(async raw => {
697
- const event = toDelivery(raw);
698
- if (event !== undefined) await handler(event);
699
- });
700
- backgroundHandlerRegistered = true;
701
- },
702
-
703
- hasBackgroundDeliveryHandler(): boolean {
704
- return backgroundHandlerRegistered;
705
- },
706
- };
707
- }
1
+ /**
2
+ * The `react-native-notify-kit` engine implementation.
3
+ *
4
+ * Every import of the engine is confined to this file. Nothing above it knows
5
+ * which engine is in use — which is what allowed the original Notifee choice to
6
+ * be swapped for its maintained fork without touching a requirement, a test, or
7
+ * a line of consumer code.
8
+ *
9
+ * The module is required lazily so the package loads in Jest and in any
10
+ * environment without the native module. An absent engine is reported through
11
+ * the normal diagnostic path rather than crashing at import time.
12
+ */
13
+
14
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
15
+
16
+ import { Platform } from 'react-native';
17
+ import type {
18
+ NotificationEngineAdapter,
19
+ DeliveryEvent,
20
+ EngineCapabilities,
21
+ EngineScheduleRequest,
22
+ RepeatStop,
23
+ ToneDeclarationOutcome,
24
+ } from './adapter';
25
+ import { isRepeatStopId } from '../repeat/marker';
26
+ import { packData, unpackData } from './codec';
27
+ import type { PendingEntry } from '../contract/notification';
28
+ import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
29
+ import type { ResolvedTone, ChannelGroup } from '../contract/channel';
30
+ import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
31
+ import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
32
+
33
+ /**
34
+ * iOS keeps only the 64 soonest pending notifications and silently discards the
35
+ * rest. This number is the entire reason truncation must be computed and
36
+ * reported by the package rather than left to the platform.
37
+ */
38
+ const IOS_PENDING_CEILING = 64;
39
+
40
+ /** Android has no comparable hard ceiling; this bound keeps behavior predictable. */
41
+ const ANDROID_PENDING_BUDGET = 500;
42
+
43
+ /** Both platforms bound background execution for a silent payload or background action. */
44
+ const BACKGROUND_WINDOW_MS = 30_000;
45
+
46
+ /**
47
+ * The engine's own enum values, mirrored as literals.
48
+ *
49
+ * These are **numeric** enums in the engine's type definitions, and its native
50
+ * validator rejects the string names — `importance: 'default'` fails with
51
+ * "expected a valid Importance". Mirroring them here rather than importing the
52
+ * enums keeps the engine out of the module graph, which is what lets the
53
+ * package load without the native module present.
54
+ *
55
+ * The package's own `ResolvedTone` deliberately stays platform-neutral strings;
56
+ * translating to the engine's representation is this adapter's job and nobody
57
+ * else's.
58
+ */
59
+ const ANDROID_IMPORTANCE = {
60
+ none: 0,
61
+ min: 1,
62
+ low: 2,
63
+ default: 3,
64
+ high: 4,
65
+ } as const;
66
+
67
+ /** `TriggerType.TIMESTAMP`. */
68
+ const TRIGGER_TYPE_TIMESTAMP = 0;
69
+
70
+ /** `AlarmType.SET_EXACT_AND_ALLOW_WHILE_IDLE` — exact delivery, survives Doze. */
71
+ const ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE = 3;
72
+
73
+ /** `AlarmType.SET_AND_ALLOW_WHILE_IDLE` — the approximate degradation of the above. */
74
+ const ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE = 1;
75
+
76
+ /**
77
+ * `EventType` values that represent a real user interaction.
78
+ *
79
+ * The engine emits far more than interactions on this channel — `DELIVERED`,
80
+ * `TRIGGER_NOTIFICATION_CREATED`, `CHANNEL_BLOCKED` and others all arrive here.
81
+ * Treating anything that is not a dismissal as a press would report a
82
+ * notification *arriving* while the app is open as the user having tapped it,
83
+ * and the interaction path cancels on tap — so the notification would vanish
84
+ * from the tray in front of the user, unread.
85
+ */
86
+ const EVENT_TYPE_DISMISSED = 0;
87
+ const EVENT_TYPE_PRESS = 1;
88
+ const EVENT_TYPE_ACTION_PRESS = 2;
89
+
90
+ /** `EventType.DELIVERED` — an arrival, which is emphatically not an interaction. */
91
+ const EVENT_TYPE_DELIVERED = 3;
92
+
93
+ /**
94
+ * `AndroidCategory.ALARM`, the engine's marking for a time-sensitive alert.
95
+ *
96
+ * A per-notification property, not channel configuration — which is why
97
+ * declaring `timeSensitive` on an existing tone never obliges a version bump.
98
+ */
99
+ const ANDROID_CATEGORY_ALARM = 'alarm';
100
+
101
+ /**
102
+ * The channel stop markers are posted on.
103
+ *
104
+ * A marker exists to be delivered and immediately removed, so it is given the
105
+ * quietest configuration the platform offers: minimum importance, no sound, no
106
+ * vibration. Its version is fixed because nothing about it is consumer-declared,
107
+ * so it never migrates.
108
+ */
109
+ const MARKER_CHANNEL_ID = 'cms.internal.stop.v1';
110
+
111
+ /**
112
+ * Whether this *process* has a background delivery handler.
113
+ *
114
+ * Module scope, not adapter instance state. The engine registers one headless
115
+ * task per process, so registering through any adapter registers it for all of
116
+ * them. Keeping the flag per instance made an app that had wired the handler up
117
+ * correctly report that it had not — which is worse than not reporting at all,
118
+ * because the warning tells the consumer to fix something that is not broken.
119
+ */
120
+ let backgroundHandlerRegistered = false;
121
+
122
+ /** Minimal shape used from the engine, so its full surface never leaks into typing. */
123
+ interface NotifyKitModule {
124
+ createChannel(channel: Record<string, unknown>): Promise<string>;
125
+ deleteChannel(channelId: string): Promise<void>;
126
+ /**
127
+ * Labels come back with the id. That is what makes a label refresh decidable
128
+ * against the device rather than against a ledger of our own — and a ledger
129
+ * drifts after a restore, a reinstall, or a change the user made themselves.
130
+ */
131
+ getChannels(): Promise<{ id: string; name?: string; description?: string }[]>;
132
+ createChannelGroup(group: Record<string, unknown>): Promise<string>;
133
+ createTriggerNotification(
134
+ notification: Record<string, unknown>,
135
+ trigger: Record<string, unknown>,
136
+ ): Promise<string>;
137
+ displayNotification(notification: Record<string, unknown>): Promise<string>;
138
+ getDisplayedNotifications(): Promise<Record<string, unknown>[]>;
139
+ cancelNotification(id: string): Promise<void>;
140
+ cancelAllNotifications(): Promise<void>;
141
+ getTriggerNotifications(): Promise<Record<string, unknown>[]>;
142
+ onBackgroundEvent(handler: (event: Record<string, unknown>) => Promise<void>): void;
143
+ requestPermission(options?: Record<string, unknown>): Promise<Record<string, unknown>>;
144
+ getNotificationSettings(): Promise<Record<string, unknown>>;
145
+ openNotificationSettings(): Promise<void>;
146
+ openAlarmPermissionSettings(): Promise<void>;
147
+ isBatteryOptimizationEnabled(): Promise<boolean>;
148
+ onForegroundEvent(handler: (event: Record<string, unknown>) => void): () => void;
149
+ getInitialNotification(): Promise<Record<string, unknown> | null>;
150
+ }
151
+
152
+ /**
153
+ * Drops keys whose value is `undefined`.
154
+ *
155
+ * The engine validates its arguments by key presence, not by value: passing
156
+ * `{ groupId: undefined }` fails with "expected a string value", while omitting
157
+ * the key entirely is accepted. JavaScript treats the two as interchangeable
158
+ * almost everywhere, which is exactly why this is easy to get wrong — and why
159
+ * it only surfaces on a real device, where the native validator runs.
160
+ */
161
+ function compact<T extends Record<string, unknown>>(value: T): Partial<T> {
162
+ const output: Record<string, unknown> = {};
163
+ for (const [key, entry] of Object.entries(value)) {
164
+ if (entry !== undefined) output[key] = entry;
165
+ }
166
+ return output as Partial<T>;
167
+ }
168
+
169
+ const notifyKit = lazyPeer('react-native-notify-kit', () => {
170
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
171
+ try {
172
+ return require('react-native-notify-kit') as { default: NotifyKitModule };
173
+ } catch (cause) {
174
+ throw cause;
175
+ }
176
+ });
177
+
178
+ function loadEngine(): NotifyKitModule {
179
+ return notifyKit.require(
180
+ (_peer, cause) =>
181
+ new NotificationError(
182
+ DIAGNOSTIC_CODES.PUSH_UNAVAILABLE,
183
+ 'react-native-notify-kit is not installed or its native module is not linked. Install it as a peer dependency and rebuild the app.',
184
+ { reason: cause instanceof Error ? cause.name : 'unknown' },
185
+ ),
186
+ ).default;
187
+ }
188
+
189
+ /**
190
+ * Maps engine authorization values onto our six-state model.
191
+ *
192
+ * `notAsked` and `denied` must stay distinguishable — being able to read state
193
+ * without prompting is worthless if the two collapse. `provisional` is its own
194
+ * state on both edges, never folded into granted or denied.
195
+ */
196
+ function toPermissionState(raw: unknown): PermissionState {
197
+ switch (raw) {
198
+ case 1:
199
+ case 'authorized':
200
+ return 'granted';
201
+ case 2:
202
+ case 'provisional':
203
+ return 'provisional';
204
+ case 0:
205
+ case 'denied':
206
+ return 'denied';
207
+ case -1:
208
+ case 'notDetermined':
209
+ return 'notAsked';
210
+ default:
211
+ return 'notAsked';
212
+ }
213
+ }
214
+
215
+ export interface NotifyKitEngineOptions {
216
+ /** Injected in tests; production resolves the real module. */
217
+ readonly module?: NotifyKitModule;
218
+ readonly now?: () => number;
219
+ }
220
+
221
+ export function createNotifyKitEngine(
222
+ options: NotifyKitEngineOptions = {},
223
+ ): NotificationEngineAdapter {
224
+ let cached: NotifyKitModule | undefined = options.module;
225
+ const engine = (): NotifyKitModule => (cached ??= loadEngine());
226
+ const now = options.now ?? (() => Date.now());
227
+
228
+ /** Reads a stop marker back out of a pending entry's data bag, if it is one. */
229
+ const unpackRepeatStop = (id: string, data: Record<string, string>): RepeatStop | undefined => {
230
+ if (!isRepeatStopId(id)) return undefined;
231
+ const targetId = data.__cms_stop_target;
232
+ const dueAt = Number.parseInt(data.__cms_stop_due ?? '', 10);
233
+ if (typeof targetId !== 'string' || !Number.isFinite(dueAt)) return undefined;
234
+ return { markerId: id, targetId, dueAt };
235
+ };
236
+
237
+ const unpackEntry = (raw: Record<string, unknown>): PendingEntry | undefined => {
238
+ const notification = (raw.notification ?? {}) as Record<string, unknown>;
239
+ const trigger = (raw.trigger ?? {}) as Record<string, unknown>;
240
+ const data = (notification.data ?? {}) as Record<string, string>;
241
+ const id = notification.id;
242
+
243
+ if (typeof id !== 'string') return undefined;
244
+
245
+ return {
246
+ id,
247
+ resolvedAt: typeof trigger.timestamp === 'number' ? trigger.timestamp : now(),
248
+ content: {
249
+ title: typeof notification.title === 'string' ? notification.title : '',
250
+ body: typeof notification.body === 'string' ? notification.body : '',
251
+ subtitle: typeof notification.subtitle === 'string' ? notification.subtitle : undefined,
252
+ },
253
+ ...unpackData(data),
254
+ };
255
+ };
256
+
257
+ const toInteraction = (raw: Record<string, unknown>): InteractionEvent | undefined => {
258
+ const eventType = raw.type;
259
+
260
+ // Only a dismissal or a press is an interaction. Everything else the
261
+ // engine emits on this channel — delivery, trigger creation, channel
262
+ // blocking — is not, and must not be reported as one.
263
+ if (
264
+ eventType !== EVENT_TYPE_DISMISSED &&
265
+ eventType !== EVENT_TYPE_PRESS &&
266
+ eventType !== EVENT_TYPE_ACTION_PRESS
267
+ ) {
268
+ return undefined;
269
+ }
270
+
271
+ const detail = (raw.detail ?? {}) as Record<string, unknown>;
272
+ const notification = (detail.notification ?? {}) as Record<string, unknown>;
273
+ const pressAction = (detail.pressAction ?? {}) as Record<string, unknown>;
274
+ const id = notification.id;
275
+
276
+ if (typeof id !== 'string') return undefined;
277
+
278
+ const data = (notification.data ?? {}) as Record<string, string>;
279
+ let routing: Record<string, unknown> | undefined;
280
+ if (typeof data.__cms_routing === 'string') {
281
+ try {
282
+ routing = JSON.parse(data.__cms_routing) as Record<string, unknown>;
283
+ } catch {
284
+ routing = undefined;
285
+ }
286
+ }
287
+
288
+ const actionId = typeof pressAction.id === 'string' ? pressAction.id : undefined;
289
+ // `default` is the engine's own press-action id for tapping the body, so
290
+ // it is a tap rather than a named action.
291
+ const isNamedAction = actionId !== undefined && actionId !== 'default';
292
+ let kind: InteractionEvent['kind'];
293
+ if (eventType === EVENT_TYPE_DISMISSED) {
294
+ kind = 'dismiss';
295
+ } else if (isNamedAction) {
296
+ kind = 'action';
297
+ } else {
298
+ kind = 'tap';
299
+ }
300
+
301
+ return {
302
+ kind,
303
+ notificationId: id,
304
+ actionId: actionId !== 'default' ? actionId : undefined,
305
+ inputText: typeof detail.input === 'string' ? detail.input : undefined,
306
+ routing,
307
+ ownerTag: data.__cms_owner,
308
+ occurredAt: now(),
309
+ appState: 'foreground',
310
+ wasBuffered: false,
311
+ };
312
+ };
313
+
314
+ /**
315
+ * Creates the marker channel the first time one is needed.
316
+ *
317
+ * Lazily rather than at initialization, so an app that never declares a
318
+ * repeating tone never grows an extra entry in the user's notification
319
+ * settings for a feature it does not use.
320
+ */
321
+ let markerChannelReady = false;
322
+ const ensureMarkerChannel = async (): Promise<void> => {
323
+ if (Platform.OS !== 'android' || markerChannelReady) return;
324
+
325
+ await engine().createChannel({
326
+ id: MARKER_CHANNEL_ID,
327
+ name: 'Background maintenance',
328
+ description:
329
+ 'Used briefly to end a repeating alert. Notifications here are removed immediately.',
330
+ importance: ANDROID_IMPORTANCE.min,
331
+ vibration: false,
332
+ badge: false,
333
+ });
334
+ markerChannelReady = true;
335
+ };
336
+
337
+ let deliveryListeners: ((event: DeliveryEvent) => void)[] = [];
338
+
339
+ /** A delivery, or undefined for anything that is not one. */
340
+ const toDelivery = (raw: Record<string, unknown>): DeliveryEvent | undefined => {
341
+ if (raw.type !== EVENT_TYPE_DELIVERED) return undefined;
342
+
343
+ const detail = (raw.detail ?? {}) as Record<string, unknown>;
344
+ const notification = (detail.notification ?? {}) as Record<string, unknown>;
345
+ const id = notification.id;
346
+ if (typeof id !== 'string') return undefined;
347
+
348
+ const data = (notification.data ?? {}) as Record<string, string>;
349
+ return {
350
+ notificationId: id,
351
+ occurredAt: now(),
352
+ repeatStop: unpackRepeatStop(id, data),
353
+ };
354
+ };
355
+
356
+ return {
357
+ name: 'react-native-notify-kit',
358
+
359
+ async getCapabilities(): Promise<EngineCapabilities> {
360
+ return {
361
+ pendingCeiling: Platform.OS === 'ios' ? IOS_PENDING_CEILING : ANDROID_PENDING_BUDGET,
362
+ supportsExactAlarms: Platform.OS === 'android',
363
+ supportsChannels: Platform.OS === 'android',
364
+ supportsTextInputActions: true,
365
+ supportsDoNotDisturbBypass: Platform.OS === 'android',
366
+ // iOS exposes no looping-sound API. The one alert level that
367
+ // overrides the mute switch is entitlement-gated by Apple, and does
368
+ // not loop either — so there is nothing to partially support, and a
369
+ // partial emulation would be worse than an honest refusal.
370
+ supportsRepeatingAlert: Platform.OS === 'android',
371
+ backgroundWindowMs: BACKGROUND_WINDOW_MS,
372
+ };
373
+ },
374
+
375
+ async schedule(request: EngineScheduleRequest): Promise<void> {
376
+ const actions = request.request.actions?.map(action =>
377
+ compact({
378
+ title: action.title,
379
+ pressAction: compact({
380
+ id: action.id,
381
+ launchActivity: action.mode === 'foreground' ? 'default' : undefined,
382
+ }),
383
+ input:
384
+ action.input === undefined
385
+ ? undefined
386
+ : compact({ placeholder: action.input.placeholder }),
387
+ }),
388
+ );
389
+
390
+ const isMarker = request.repeatStop !== undefined;
391
+ if (isMarker) {
392
+ await ensureMarkerChannel();
393
+ }
394
+
395
+ await engine().createTriggerNotification(
396
+ compact({
397
+ id: request.request.id,
398
+ title: request.request.content.title,
399
+ body: request.request.content.body,
400
+ subtitle: request.request.content.subtitle,
401
+ data: packData(request),
402
+ android: compact({
403
+ // A marker gets the quietest channel the package owns rather than
404
+ // the consumer's tone. It exists to be delivered and removed, and
405
+ // borrowing the alarm's own channel would make it alarm too.
406
+ channelId: isMarker ? MARKER_CHANNEL_ID : request.tone.android.channelId,
407
+ // Numeric enum, not the string name — see ANDROID_IMPORTANCE.
408
+ importance: isMarker
409
+ ? ANDROID_IMPORTANCE.min
410
+ : ANDROID_IMPORTANCE[request.tone.android.importance],
411
+ sound: isMarker ? undefined : request.tone.android.sound,
412
+ groupId: isMarker ? undefined : request.tone.android.groupId,
413
+ // Per-notification, not channel configuration. That is what lets
414
+ // a consumer add a repeating alert to an existing tone without
415
+ // bumping its version and costing the user their settings.
416
+ loopSound: !isMarker && request.tone.android.loopSound ? true : undefined,
417
+ category:
418
+ !isMarker && request.tone.android.alarmCategory ? ANDROID_CATEGORY_ALARM : undefined,
419
+ actions: isMarker ? undefined : actions,
420
+ }),
421
+ ios: compact({
422
+ categoryId: request.tone.ios.categoryId,
423
+ // This one genuinely is a string union in the engine's types.
424
+ interruptionLevel: request.tone.ios.interruptionLevel,
425
+ sound: request.tone.ios.sound,
426
+ }),
427
+ }),
428
+ {
429
+ type: TRIGGER_TYPE_TIMESTAMP,
430
+ timestamp: request.resolvedAt,
431
+ // Approximate mode is a degradation, not a failure: the notification
432
+ // is still scheduled and the caller is told it may arrive late.
433
+ alarmManager: {
434
+ allowWhileIdle: true,
435
+ type: request.approximate
436
+ ? ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE
437
+ : ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE,
438
+ },
439
+ },
440
+ );
441
+ },
442
+
443
+ async cancel(id: string): Promise<void> {
444
+ await engine().cancelNotification(id);
445
+ },
446
+
447
+ async cancelMany(ids: readonly string[]): Promise<void> {
448
+ // Sequential rather than parallel: the engine bridges each call to
449
+ // native, and a burst of concurrent cancels is where duplicate-delivery
450
+ // races have historically appeared.
451
+ for (const id of ids) {
452
+ await engine().cancelNotification(id);
453
+ }
454
+ },
455
+
456
+ async cancelAll(): Promise<void> {
457
+ await engine().cancelAllNotifications();
458
+ },
459
+
460
+ async getPending(): Promise<readonly PendingEntry[]> {
461
+ const raw = await engine().getTriggerNotifications();
462
+ return (
463
+ raw
464
+ .map(unpackEntry)
465
+ .filter((entry): entry is PendingEntry => entry !== undefined)
466
+ // Stop markers are package-owned artifacts. A consumer diffing the
467
+ // device against its own intent would otherwise see a notification it
468
+ // never scheduled and, quite reasonably, cancel it.
469
+ .filter(entry => !isRepeatStopId(entry.id))
470
+ .sort((a, b) => a.resolvedAt - b.resolvedAt)
471
+ );
472
+ },
473
+
474
+ async declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome> {
475
+ if (Platform.OS !== 'android') {
476
+ // iOS categories are declared at request time rather than up front.
477
+ return 'unchanged';
478
+ }
479
+
480
+ const existing = await engine().getChannels();
481
+ const held = existing.find(channel => channel.id === tone.android.channelId);
482
+
483
+ const payload = compact({
484
+ id: tone.android.channelId,
485
+ // The consumer's declared label, not the tone identifier. Passing the
486
+ // identifier here is what put a technical string in front of users on
487
+ // the one screen these labels exist to serve.
488
+ name: tone.android.name,
489
+ description: tone.android.description,
490
+ importance: ANDROID_IMPORTANCE[tone.android.importance],
491
+ sound: tone.android.sound,
492
+ vibration: tone.android.vibration,
493
+ badge: tone.android.badge,
494
+ bypassDnd: tone.android.bypassDnd,
495
+ groupId: tone.android.groupId,
496
+ });
497
+
498
+ if (held === undefined) {
499
+ await engine().createChannel(payload);
500
+ return 'created';
501
+ }
502
+
503
+ if (held.name === tone.android.name && held.description === tone.android.description) {
504
+ // Nothing to say to the platform. Skipping the call is what keeps an
505
+ // unchanged configuration genuinely free, which reconciliation
506
+ // idempotence depends on.
507
+ return 'unchanged';
508
+ }
509
+
510
+ // Android accepts a re-declaration of an existing channel and applies
511
+ // only the name and description, ignoring importance, sound, vibration
512
+ // and DND — because those belong to the user once the channel exists.
513
+ // That refusal is the guarantee: we do not have to protect the user's
514
+ // settings here, the platform declines to overwrite them.
515
+ await engine().createChannel(payload);
516
+ return 'labelsRefreshed';
517
+ },
518
+
519
+ async retireTone(channelId: string): Promise<boolean> {
520
+ if (Platform.OS !== 'android') return false;
521
+
522
+ const existing = await engine().getChannels();
523
+ if (!existing.some(channel => channel.id === channelId)) return false;
524
+
525
+ await engine().deleteChannel(channelId);
526
+ return true;
527
+ },
528
+
529
+ async declareGroups(groups: readonly ChannelGroup[]): Promise<void> {
530
+ if (Platform.OS !== 'android') return;
531
+
532
+ for (const group of groups) {
533
+ await engine().createChannelGroup(
534
+ compact({ id: group.id, name: group.name, description: group.description }),
535
+ );
536
+ }
537
+ },
538
+
539
+ async listDeclaredChannels(): Promise<readonly string[]> {
540
+ if (Platform.OS !== 'android') return [];
541
+ const channels = await engine().getChannels();
542
+ return channels.map(channel => channel.id);
543
+ },
544
+
545
+ async getPermission(kind: PermissionKind): Promise<PermissionState> {
546
+ const settings = await engine().getNotificationSettings();
547
+
548
+ if (kind === 'display') {
549
+ // Android below 13 has no runtime display permission at all.
550
+ if (Platform.OS === 'android' && (Platform.Version as number) < 33) {
551
+ return 'notRequired';
552
+ }
553
+ return toPermissionState(settings.authorizationStatus);
554
+ }
555
+
556
+ // Exact alarms exist only on Android 12+; elsewhere the concept does not
557
+ // apply, which is "not required" rather than "denied".
558
+ if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
559
+ return 'notRequired';
560
+ }
561
+
562
+ const android = (settings.android ?? {}) as Record<string, unknown>;
563
+ return android.alarm === 1 ? 'granted' : 'denied';
564
+ },
565
+
566
+ async requestPermission(kind: PermissionKind): Promise<PermissionState> {
567
+ if (kind === 'display') {
568
+ const result = await engine().requestPermission();
569
+ return toPermissionState(result.authorizationStatus);
570
+ }
571
+
572
+ // Exact-alarm capability is granted only through system settings on the
573
+ // versions that gate it — there is no in-app dialog to present.
574
+ if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
575
+ return 'notRequired';
576
+ }
577
+ await engine().openAlarmPermissionSettings();
578
+ return this.getPermission('exactAlarm');
579
+ },
580
+
581
+ async openSettings(kind: PermissionKind): Promise<void> {
582
+ if (kind === 'exactAlarm') {
583
+ if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
584
+ throw new NotificationError(
585
+ DIAGNOSTIC_CODES.SETTINGS_UNAVAILABLE,
586
+ 'No exact-alarm settings screen exists on this platform version.',
587
+ { platform: Platform.OS, version: Platform.Version },
588
+ );
589
+ }
590
+ await engine().openAlarmPermissionSettings();
591
+ return;
592
+ }
593
+ await engine().openNotificationSettings();
594
+ },
595
+
596
+ async getRestrictions(): Promise<RestrictionReport> {
597
+ if (Platform.OS !== 'android') {
598
+ return {
599
+ batteryOptimization: 'unrestricted',
600
+ autoStart: 'unrestricted',
601
+ readAt: now(),
602
+ };
603
+ }
604
+
605
+ let batteryOptimization: RestrictionReport['batteryOptimization'] = 'unknown';
606
+ try {
607
+ batteryOptimization = (await engine().isBatteryOptimizationEnabled())
608
+ ? 'restricted'
609
+ : 'unrestricted';
610
+ } catch {
611
+ batteryOptimization = 'unknown';
612
+ }
613
+
614
+ return {
615
+ batteryOptimization,
616
+ // No standard API exposes vendor auto-start policy, and guessing would
617
+ // be worse than admitting it. `unknown` is a first-class answer here.
618
+ autoStart: 'unknown',
619
+ readAt: now(),
620
+ };
621
+ },
622
+
623
+ subscribeToInteractions(handler: (event: InteractionEvent) => void): Unsubscribe {
624
+ return engine().onForegroundEvent(raw => {
625
+ const event = toInteraction(raw);
626
+ if (event !== undefined) handler(event);
627
+ });
628
+ },
629
+
630
+ async takeInitialInteraction(): Promise<InteractionEvent | undefined> {
631
+ const raw = await engine().getInitialNotification();
632
+ if (raw === null) return undefined;
633
+
634
+ const event = toInteraction(raw);
635
+ // A cold-start tap arrives before any handler exists, so it is reported
636
+ // with the state that actually produced it.
637
+ return event === undefined ? undefined : { ...event, appState: 'coldStart' };
638
+ },
639
+
640
+ async silenceDelivered(id: string): Promise<boolean> {
641
+ const displayed = await engine().getDisplayedNotifications();
642
+ const match = displayed.find(entry => {
643
+ const notification = (entry.notification ?? entry) as Record<string, unknown>;
644
+ return notification.id === id;
645
+ });
646
+
647
+ // Not displayed is the ordinary outcome, not a failure: the user handled
648
+ // it before the window ran out.
649
+ if (match === undefined) return false;
650
+
651
+ const notification = (match.notification ?? match) as Record<string, unknown>;
652
+ const android = (notification.android ?? {}) as Record<string, unknown>;
653
+
654
+ // Re-posted under the *same* identifier, so it lands on the same tray
655
+ // slot and replaces in place. Cancelling instead would remove it, and a
656
+ // notification that vanished is worse than one that never sounded — the
657
+ // user comes home to a silent phone and no idea what they missed.
658
+ await engine().displayNotification({
659
+ ...notification,
660
+ android: {
661
+ ...android,
662
+ loopSound: false,
663
+ // Without this the replacement re-alerts once on its way to going
664
+ // quiet, which is a strange thing to do to someone who has already
665
+ // heard it for fifteen minutes.
666
+ onlyAlertOnce: true,
667
+ },
668
+ });
669
+ return true;
670
+ },
671
+
672
+ async listRepeatStops(): Promise<readonly RepeatStop[]> {
673
+ const raw = await engine().getTriggerNotifications();
674
+ const stops: RepeatStop[] = [];
675
+
676
+ for (const entry of raw) {
677
+ const notification = (entry.notification ?? {}) as Record<string, unknown>;
678
+ const id = notification.id;
679
+ if (typeof id !== 'string') continue;
680
+
681
+ const data = (notification.data ?? {}) as Record<string, string>;
682
+ const stop = unpackRepeatStop(id, data);
683
+ if (stop !== undefined) stops.push(stop);
684
+ }
685
+
686
+ return stops;
687
+ },
688
+
689
+ subscribeToDeliveries(handler: (event: DeliveryEvent) => void): Unsubscribe {
690
+ deliveryListeners.push(handler);
691
+
692
+ const unsubscribe = engine().onForegroundEvent(raw => {
693
+ const event = toDelivery(raw);
694
+ if (event !== undefined) handler(event);
695
+ });
696
+
697
+ return () => {
698
+ deliveryListeners = deliveryListeners.filter(l => l !== handler);
699
+ unsubscribe();
700
+ };
701
+ },
702
+
703
+ registerBackgroundDeliveryHandler(handler: (event: DeliveryEvent) => Promise<void>): void {
704
+ engine().onBackgroundEvent(async raw => {
705
+ const event = toDelivery(raw);
706
+ if (event !== undefined) await handler(event);
707
+ });
708
+ backgroundHandlerRegistered = true;
709
+ },
710
+
711
+ hasBackgroundDeliveryHandler(): boolean {
712
+ return backgroundHandlerRegistered;
713
+ },
714
+ };
715
+ }