@chipmobilesdk/rn-notification 0.4.1 → 0.6.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 (45) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +108 -12
  3. package/package.json +10 -2
  4. package/skills/sdk-rn-notification/SKILL.md +6 -5
  5. package/src/advanced.ts +35 -0
  6. package/src/channel/migrate.ts +176 -158
  7. package/src/channel/tone-map.ts +2 -10
  8. package/src/contract/errors.ts +21 -6
  9. package/src/contract/hash.ts +26 -0
  10. package/src/diagnostics/events.ts +10 -1
  11. package/src/diagnostics/redact.ts +9 -26
  12. package/src/diagnostics/snapshot.ts +34 -1
  13. package/src/engine/adapter.ts +21 -1
  14. package/src/engine/notify-kit/events.ts +155 -0
  15. package/src/engine/notify-kit/module.ts +148 -0
  16. package/src/engine/notify-kit/platform/android.ts +172 -0
  17. package/src/engine/notify-kit/platform/index.ts +55 -0
  18. package/src/engine/notify-kit/platform/ios.ts +54 -0
  19. package/src/engine/notify-kit/platform/types.ts +40 -0
  20. package/src/engine/notify-kit-engine.ts +96 -468
  21. package/src/index.ts +9 -0
  22. package/src/initialize.ts +10 -3
  23. package/src/notifications.ts +195 -0
  24. package/src/permission/index.ts +65 -4
  25. package/src/push/fcm-provider.ts +28 -11
  26. package/src/push/gate.ts +27 -5
  27. package/src/reconcile/diff.ts +38 -27
  28. package/src/reconcile/index.ts +13 -4
  29. package/src/recurrence/index.ts +2 -1
  30. package/src/repeat/expiry.ts +2 -6
  31. package/src/repeat/index.ts +45 -1
  32. package/src/repeat/marker.ts +1 -22
  33. package/src/repeat/place.ts +74 -35
  34. package/src/schedule/anchor.ts +35 -19
  35. package/src/schedule/index.ts +10 -4
  36. package/src/schedule/partition.ts +0 -19
  37. package/src/schedule/validate-identifier.ts +8 -6
  38. package/src/schedule/writer.ts +13 -4
  39. package/src/state/caps.ts +28 -3
  40. package/src/state/interaction-buffer.ts +30 -2
  41. package/src/state/mmkv-store.ts +54 -37
  42. package/src/state/permission-history.ts +25 -0
  43. package/src/state/record.ts +114 -20
  44. package/src/state/store.ts +3 -0
  45. package/src/testing/fake-engine.ts +17 -1
@@ -1,110 +1,49 @@
1
1
  /**
2
2
  * The `react-native-notify-kit` engine implementation.
3
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.
4
+ * Nothing above this adapter knows which engine is in use — which is what
5
+ * allowed the original Notifee choice to be swapped for its maintained fork
6
+ * without touching a requirement, a test, or a line of consumer code.
8
7
  *
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.
8
+ * This file is what both platforms share: scheduling, cancelling, reading the
9
+ * pending set, and routing events. The engine module and its constants live in
10
+ * `notify-kit/module.ts`, decoding in `notify-kit/events.ts`, and everything
11
+ * that differs between Android and iOS in `notify-kit/platform/` — chosen once
12
+ * when the engine is created (NOT-18, IOS-08).
12
13
  */
13
14
 
14
- import { Platform } from 'react-native';
15
15
  import type {
16
16
  NotificationEngineAdapter,
17
17
  DeliveryEvent,
18
- EngineCapabilities,
19
18
  EngineScheduleRequest,
20
19
  RepeatStop,
21
- ToneDeclarationOutcome,
22
20
  } from './adapter';
23
21
  import { isRepeatStopId } from '../repeat/marker';
24
- import { packData, unpackData } from './codec';
22
+ import { packData } from './codec';
25
23
  import type { PendingEntry } from '../contract/notification';
26
- import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
27
- import type { ResolvedTone, ChannelGroup } from '../contract/channel';
24
+ import type { PermissionKind, PermissionState } from '../contract/permission';
28
25
  import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
29
26
  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';
27
+ import type { PlatformInfo } from '../permission/state';
28
+ import {
29
+ toDelivery,
30
+ toInteraction,
31
+ toPermissionState,
32
+ unpackEntry,
33
+ unpackRepeatStops,
34
+ } from './notify-kit/events';
35
+ import {
36
+ ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE,
37
+ ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE,
38
+ ANDROID_CATEGORY_ALARM,
39
+ ANDROID_IMPORTANCE,
40
+ MARKER_CHANNEL_ID,
41
+ TRIGGER_TYPE_TIMESTAMP,
42
+ compact,
43
+ loadEngine,
44
+ type NotifyKitModule,
45
+ } from './notify-kit/module';
46
+ import { createNotifyKitPlatform } from './notify-kit/platform';
108
47
 
109
48
  /**
110
49
  * Whether this *process* has a background delivery handler.
@@ -116,97 +55,20 @@ const MARKER_CHANNEL_ID = 'cms.internal.stop.v1';
116
55
  * because the warning tells the consumer to fix something that is not broken.
117
56
  */
118
57
  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
58
  /**
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.
59
+ * Interaction subscribers alive in this process, across engine instances: the
60
+ * one registered at module scope in `index.js` and the one the app's runtime
61
+ * uses are different instances, and a background interaction has to reach the
62
+ * runtime's subscribers when there are any (NOT-10).
186
63
  */
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
- }
64
+ const liveInteractionListeners = new Set<(event: InteractionEvent) => void>();
205
65
 
206
66
  export interface NotifyKitEngineOptions {
207
67
  /** Injected in tests; production resolves the real module. */
208
68
  readonly module?: NotifyKitModule;
209
69
  readonly now?: () => number;
70
+ /** Defaults to the running platform. Injected to test one platform's behaviour. */
71
+ readonly platform?: PlatformInfo;
210
72
  }
211
73
 
212
74
  export function createNotifyKitEngine(
@@ -215,154 +77,29 @@ export function createNotifyKitEngine(
215
77
  let cached: NotifyKitModule | undefined = options.module;
216
78
  const engine = (): NotifyKitModule => (cached ??= loadEngine());
217
79
  const now = options.now ?? (() => Date.now());
80
+ const platform = createNotifyKitPlatform(engine, options.platform);
218
81
 
219
- /** Reads a stop marker back out of a pending entry's data bag, if it is one. */
220
- const unpackRepeatStop = (id: string, data: Record<string, string>): RepeatStop | undefined => {
221
- if (!isRepeatStopId(id)) return undefined;
222
- const targetId = data.__cms_stop_target;
223
- const dueAt = Number.parseInt(data.__cms_stop_due ?? '', 10);
224
- if (typeof targetId !== 'string' || !Number.isFinite(dueAt)) return undefined;
225
- return { markerId: id, targetId, dueAt };
226
- };
227
-
228
- const unpackEntry = (raw: Record<string, unknown>): PendingEntry | undefined => {
229
- const notification = (raw.notification ?? {}) as Record<string, unknown>;
230
- const trigger = (raw.trigger ?? {}) as Record<string, unknown>;
231
- const data = (notification.data ?? {}) as Record<string, string>;
232
- const id = notification.id;
233
-
234
- if (typeof id !== 'string') return undefined;
235
-
236
- return {
237
- id,
238
- resolvedAt: typeof trigger.timestamp === 'number' ? trigger.timestamp : now(),
239
- content: {
240
- title: typeof notification.title === 'string' ? notification.title : '',
241
- body: typeof notification.body === 'string' ? notification.body : '',
242
- subtitle: typeof notification.subtitle === 'string' ? notification.subtitle : undefined,
243
- },
244
- ...unpackData(data),
245
- };
246
- };
247
-
248
- const toInteraction = (raw: Record<string, unknown>): InteractionEvent | undefined => {
249
- const eventType = raw.type;
250
-
251
- // Only a dismissal or a press is an interaction. Everything else the
252
- // engine emits on this channel — delivery, trigger creation, channel
253
- // blocking — is not, and must not be reported as one.
254
- if (
255
- eventType !== EVENT_TYPE_DISMISSED &&
256
- eventType !== EVENT_TYPE_PRESS &&
257
- eventType !== EVENT_TYPE_ACTION_PRESS
258
- ) {
259
- return undefined;
260
- }
261
-
262
- const detail = (raw.detail ?? {}) as Record<string, unknown>;
263
- const notification = (detail.notification ?? {}) as Record<string, unknown>;
264
- const pressAction = (detail.pressAction ?? {}) as Record<string, unknown>;
265
- const id = notification.id;
266
-
267
- if (typeof id !== 'string') return undefined;
268
-
269
- const data = (notification.data ?? {}) as Record<string, string>;
270
- let routing: Record<string, unknown> | undefined;
271
- if (typeof data.__cms_routing === 'string') {
272
- try {
273
- routing = JSON.parse(data.__cms_routing) as Record<string, unknown>;
274
- } catch {
275
- routing = undefined;
276
- }
277
- }
278
-
279
- const actionId = typeof pressAction.id === 'string' ? pressAction.id : undefined;
280
- // `default` is the engine's own press-action id for tapping the body, so
281
- // it is a tap rather than a named action.
282
- const isNamedAction = actionId !== undefined && actionId !== 'default';
283
- let kind: InteractionEvent['kind'];
284
- if (eventType === EVENT_TYPE_DISMISSED) {
285
- kind = 'dismiss';
286
- } else if (isNamedAction) {
287
- kind = 'action';
288
- } else {
289
- kind = 'tap';
290
- }
291
-
292
- return {
293
- kind,
294
- notificationId: id,
295
- actionId: actionId !== 'default' ? actionId : undefined,
296
- inputText: typeof detail.input === 'string' ? detail.input : undefined,
297
- routing,
298
- ownerTag: data.__cms_owner,
299
- occurredAt: now(),
300
- appState: 'foreground',
301
- wasBuffered: false,
302
- };
303
- };
304
-
305
- /**
306
- * Creates the marker channel the first time one is needed.
307
- *
308
- * Lazily rather than at initialization, so an app that never declares a
309
- * repeating tone never grows an extra entry in the user's notification
310
- * settings for a feature it does not use.
311
- */
312
- let markerChannelReady = false;
313
- const ensureMarkerChannel = async (): Promise<void> => {
314
- if (Platform.OS !== 'android' || markerChannelReady) return;
315
-
316
- await engine().createChannel({
317
- id: MARKER_CHANNEL_ID,
318
- name: 'Background maintenance',
319
- description:
320
- 'Used briefly to end a repeating alert. Notifications here are removed immediately.',
321
- importance: ANDROID_IMPORTANCE.min,
322
- vibration: false,
323
- badge: false,
324
- });
325
- markerChannelReady = true;
326
- };
327
-
328
- let deliveryListeners: ((event: DeliveryEvent) => void)[] = [];
329
-
330
- /** A delivery, or undefined for anything that is not one. */
331
- const toDelivery = (raw: Record<string, unknown>): DeliveryEvent | undefined => {
332
- if (raw.type !== EVENT_TYPE_DELIVERED) return undefined;
333
-
334
- const detail = (raw.detail ?? {}) as Record<string, unknown>;
335
- const notification = (detail.notification ?? {}) as Record<string, unknown>;
336
- const id = notification.id;
337
- if (typeof id !== 'string') return undefined;
338
-
339
- const data = (notification.data ?? {}) as Record<string, string>;
340
- return {
341
- notificationId: id,
342
- occurredAt: now(),
343
- repeatStop: unpackRepeatStop(id, data),
344
- };
345
- };
82
+ const getPermission = async (kind: PermissionKind): Promise<PermissionState> =>
83
+ platform.permissionFrom(kind, await engine().getNotificationSettings());
346
84
 
347
85
  return {
348
86
  name: 'react-native-notify-kit',
349
87
 
350
- async getCapabilities(): Promise<EngineCapabilities> {
351
- return {
352
- pendingCeiling: Platform.OS === 'ios' ? IOS_PENDING_CEILING : ANDROID_PENDING_BUDGET,
353
- supportsExactAlarms: Platform.OS === 'android',
354
- supportsChannels: Platform.OS === 'android',
355
- supportsTextInputActions: true,
356
- supportsDoNotDisturbBypass: Platform.OS === 'android',
357
- // iOS exposes no looping-sound API. The one alert level that
358
- // overrides the mute switch is entitlement-gated by Apple, and does
359
- // not loop either — so there is nothing to partially support, and a
360
- // partial emulation would be worse than an honest refusal.
361
- supportsRepeatingAlert: Platform.OS === 'android',
362
- backgroundWindowMs: BACKGROUND_WINDOW_MS,
363
- };
88
+ withChannelSnapshot: work => platform.withChannelSnapshot(work),
89
+
90
+ async pendingIds(): Promise<readonly string[]> {
91
+ const module = engine();
92
+ if (module.getTriggerNotificationIds) {
93
+ return module.getTriggerNotificationIds();
94
+ }
95
+ return (await module.getTriggerNotifications()).flatMap(raw => {
96
+ const id = (raw as { notification?: { id?: unknown } }).notification?.id;
97
+ return typeof id === 'string' ? [id] : [];
98
+ });
364
99
  },
365
100
 
101
+ getCapabilities: async () => platform.capabilities(),
102
+
366
103
  async schedule(request: EngineScheduleRequest): Promise<void> {
367
104
  const actions = request.request.actions?.map(action =>
368
105
  compact({
@@ -380,7 +117,7 @@ export function createNotifyKitEngine(
380
117
 
381
118
  const isMarker = request.repeatStop !== undefined;
382
119
  if (isMarker) {
383
- await ensureMarkerChannel();
120
+ await platform.ensureMarkerChannel();
384
121
  }
385
122
 
386
123
  await engine().createTriggerNotification(
@@ -452,7 +189,7 @@ export function createNotifyKitEngine(
452
189
  const raw = await engine().getTriggerNotifications();
453
190
  return (
454
191
  raw
455
- .map(unpackEntry)
192
+ .map(entry => unpackEntry(entry, now))
456
193
  .filter((entry): entry is PendingEntry => entry !== undefined)
457
194
  // Stop markers are package-owned artifacts. A consumer diffing the
458
195
  // device against its own intent would otherwise see a notification it
@@ -462,120 +199,33 @@ export function createNotifyKitEngine(
462
199
  );
463
200
  },
464
201
 
465
- async declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome> {
466
- if (Platform.OS !== 'android') {
467
- // iOS categories are declared at request time rather than up front.
468
- return 'unchanged';
469
- }
470
-
471
- const existing = await engine().getChannels();
472
- const held = existing.find(channel => channel.id === tone.android.channelId);
473
-
474
- const payload = compact({
475
- id: tone.android.channelId,
476
- // The consumer's declared label, not the tone identifier. Passing the
477
- // identifier here is what put a technical string in front of users on
478
- // the one screen these labels exist to serve.
479
- name: tone.android.name,
480
- description: tone.android.description,
481
- importance: ANDROID_IMPORTANCE[tone.android.importance],
482
- sound: tone.android.sound,
483
- vibration: tone.android.vibration,
484
- badge: tone.android.badge,
485
- bypassDnd: tone.android.bypassDnd,
486
- groupId: tone.android.groupId,
487
- });
488
-
489
- if (held === undefined) {
490
- await engine().createChannel(payload);
491
- return 'created';
492
- }
493
-
494
- if (held.name === tone.android.name && held.description === tone.android.description) {
495
- // Nothing to say to the platform. Skipping the call is what keeps an
496
- // unchanged configuration genuinely free, which reconciliation
497
- // idempotence depends on.
498
- return 'unchanged';
499
- }
500
-
501
- // Android accepts a re-declaration of an existing channel and applies
502
- // only the name and description, ignoring importance, sound, vibration
503
- // and DND — because those belong to the user once the channel exists.
504
- // That refusal is the guarantee: we do not have to protect the user's
505
- // settings here, the platform declines to overwrite them.
506
- await engine().createChannel(payload);
507
- return 'labelsRefreshed';
508
- },
509
-
510
- async retireTone(channelId: string): Promise<boolean> {
511
- if (Platform.OS !== 'android') return false;
512
-
513
- const existing = await engine().getChannels();
514
- if (!existing.some(channel => channel.id === channelId)) return false;
515
-
516
- await engine().deleteChannel(channelId);
517
- return true;
518
- },
202
+ declareTone: tone => platform.declareTone(tone),
203
+ retireTone: channelId => platform.retireTone(channelId),
204
+ declareGroups: groups => platform.declareGroups(groups),
205
+ listDeclaredChannels: () => platform.listDeclaredChannels(),
519
206
 
520
- async declareGroups(groups: readonly ChannelGroup[]): Promise<void> {
521
- if (Platform.OS !== 'android') return;
522
-
523
- for (const group of groups) {
524
- await engine().createChannelGroup(
525
- compact({ id: group.id, name: group.name, description: group.description }),
526
- );
527
- }
528
- },
529
-
530
- async listDeclaredChannels(): Promise<readonly string[]> {
531
- if (Platform.OS !== 'android') return [];
532
- const channels = await engine().getChannels();
533
- return channels.map(channel => channel.id);
534
- },
535
-
536
- async getPermission(kind: PermissionKind): Promise<PermissionState> {
537
- const settings = await engine().getNotificationSettings();
538
-
539
- if (kind === 'display') {
540
- // Android below 13 has no runtime display permission at all.
541
- if (Platform.OS === 'android' && (Platform.Version as number) < 33) {
542
- return 'notRequired';
543
- }
544
- return toPermissionState(settings.authorizationStatus);
545
- }
546
-
547
- // Exact alarms exist only on Android 12+; elsewhere the concept does not
548
- // apply, which is "not required" rather than "denied".
549
- if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
550
- return 'notRequired';
551
- }
552
-
553
- const android = (settings.android ?? {}) as Record<string, unknown>;
554
- return android.alarm === 1 ? 'granted' : 'denied';
555
- },
207
+ getPermission,
556
208
 
557
209
  async requestPermission(kind: PermissionKind): Promise<PermissionState> {
558
210
  if (kind === 'display') {
559
211
  const result = await engine().requestPermission();
560
212
  return toPermissionState(result.authorizationStatus);
561
213
  }
562
-
563
214
  // Exact-alarm capability is granted only through system settings on the
564
215
  // versions that gate it — there is no in-app dialog to present.
565
- if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
216
+ if (!platform.gatesExactAlarms()) {
566
217
  return 'notRequired';
567
218
  }
568
219
  await engine().openAlarmPermissionSettings();
569
- return this.getPermission('exactAlarm');
220
+ return getPermission('exactAlarm');
570
221
  },
571
222
 
572
223
  async openSettings(kind: PermissionKind): Promise<void> {
573
224
  if (kind === 'exactAlarm') {
574
- if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
225
+ if (!platform.gatesExactAlarms()) {
575
226
  throw new NotificationError(
576
227
  DIAGNOSTIC_CODES.SETTINGS_UNAVAILABLE,
577
228
  'No exact-alarm settings screen exists on this platform version.',
578
- { platform: Platform.OS, version: Platform.Version },
579
229
  );
580
230
  }
581
231
  await engine().openAlarmPermissionSettings();
@@ -584,45 +234,25 @@ export function createNotifyKitEngine(
584
234
  await engine().openNotificationSettings();
585
235
  },
586
236
 
587
- async getRestrictions(): Promise<RestrictionReport> {
588
- if (Platform.OS !== 'android') {
589
- return {
590
- batteryOptimization: 'unrestricted',
591
- autoStart: 'unrestricted',
592
- readAt: now(),
593
- };
594
- }
595
-
596
- let batteryOptimization: RestrictionReport['batteryOptimization'] = 'unknown';
597
- try {
598
- batteryOptimization = (await engine().isBatteryOptimizationEnabled())
599
- ? 'restricted'
600
- : 'unrestricted';
601
- } catch {
602
- batteryOptimization = 'unknown';
603
- }
604
-
605
- return {
606
- batteryOptimization,
607
- // No standard API exposes vendor auto-start policy, and guessing would
608
- // be worse than admitting it. `unknown` is a first-class answer here.
609
- autoStart: 'unknown',
610
- readAt: now(),
611
- };
612
- },
237
+ getRestrictions: () => platform.restrictions(now),
613
238
 
614
239
  subscribeToInteractions(handler: (event: InteractionEvent) => void): Unsubscribe {
615
- return engine().onForegroundEvent(raw => {
616
- const event = toInteraction(raw);
240
+ liveInteractionListeners.add(handler);
241
+ const unsubscribe = engine().onForegroundEvent(raw => {
242
+ const event = toInteraction(raw, now);
617
243
  if (event !== undefined) handler(event);
618
244
  });
245
+ return () => {
246
+ liveInteractionListeners.delete(handler);
247
+ unsubscribe();
248
+ };
619
249
  },
620
250
 
621
251
  async takeInitialInteraction(): Promise<InteractionEvent | undefined> {
622
252
  const raw = await engine().getInitialNotification();
623
253
  if (raw === null) return undefined;
624
254
 
625
- const event = toInteraction(raw);
255
+ const event = toInteraction(raw, now);
626
256
  // A cold-start tap arrives before any handler exists, so it is reported
627
257
  // with the state that actually produced it.
628
258
  return event === undefined ? undefined : { ...event, appState: 'coldStart' };
@@ -661,40 +291,38 @@ export function createNotifyKitEngine(
661
291
  },
662
292
 
663
293
  async listRepeatStops(): Promise<readonly RepeatStop[]> {
664
- const raw = await engine().getTriggerNotifications();
665
- const stops: RepeatStop[] = [];
666
-
667
- for (const entry of raw) {
668
- const notification = (entry.notification ?? {}) as Record<string, unknown>;
669
- const id = notification.id;
670
- if (typeof id !== 'string') continue;
671
-
672
- const data = (notification.data ?? {}) as Record<string, string>;
673
- const stop = unpackRepeatStop(id, data);
674
- if (stop !== undefined) stops.push(stop);
675
- }
676
-
677
- return stops;
294
+ return unpackRepeatStops(await engine().getTriggerNotifications());
678
295
  },
679
296
 
680
297
  subscribeToDeliveries(handler: (event: DeliveryEvent) => void): Unsubscribe {
681
- deliveryListeners.push(handler);
682
-
683
- const unsubscribe = engine().onForegroundEvent(raw => {
684
- const event = toDelivery(raw);
298
+ return engine().onForegroundEvent(raw => {
299
+ const event = toDelivery(raw, now);
685
300
  if (event !== undefined) handler(event);
686
301
  });
687
-
688
- return () => {
689
- deliveryListeners = deliveryListeners.filter(l => l !== handler);
690
- unsubscribe();
691
- };
692
302
  },
693
303
 
694
- registerBackgroundDeliveryHandler(handler: (event: DeliveryEvent) => Promise<void>): void {
304
+ registerBackgroundDeliveryHandler(
305
+ handler: (event: DeliveryEvent) => Promise<void>,
306
+ onInteraction?: (event: InteractionEvent) => Promise<void>,
307
+ ): void {
695
308
  engine().onBackgroundEvent(async raw => {
696
- const event = toDelivery(raw);
697
- if (event !== undefined) await handler(event);
309
+ const delivery = toDelivery(raw, now);
310
+ if (delivery !== undefined) {
311
+ await handler(delivery);
312
+ return;
313
+ }
314
+ // A tap, an action or a dismissal while the app was not in front. Until
315
+ // 0.6.0 only deliveries were looked at here, so a background action
316
+ // ("Taken", "Snooze", an inline reply) never reached the app (NOT-10).
317
+ const interaction = toInteraction(raw, now);
318
+ if (interaction === undefined) return;
319
+ const event: InteractionEvent = { ...interaction, appState: 'background' };
320
+ if (liveInteractionListeners.size > 0) {
321
+ // The app's runtime is alive: its handlers get it, as in the foreground.
322
+ for (const listener of [...liveInteractionListeners]) listener(event);
323
+ } else {
324
+ await onInteraction?.(event);
325
+ }
698
326
  });
699
327
  backgroundHandlerRegistered = true;
700
328
  },