@chipmobilesdk/rn-notification 0.5.0 → 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 (37) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +100 -10
  3. package/package.json +6 -1
  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 +7 -0
  9. package/src/contract/hash.ts +26 -0
  10. package/src/engine/adapter.ts +21 -1
  11. package/src/engine/notify-kit/events.ts +155 -0
  12. package/src/engine/notify-kit/module.ts +148 -0
  13. package/src/engine/notify-kit/platform/android.ts +172 -0
  14. package/src/engine/notify-kit/platform/index.ts +55 -0
  15. package/src/engine/notify-kit/platform/ios.ts +54 -0
  16. package/src/engine/notify-kit/platform/types.ts +40 -0
  17. package/src/engine/notify-kit-engine.ts +96 -477
  18. package/src/index.ts +9 -0
  19. package/src/notifications.ts +195 -0
  20. package/src/permission/index.ts +65 -4
  21. package/src/push/fcm-provider.ts +14 -4
  22. package/src/push/gate.ts +27 -5
  23. package/src/reconcile/diff.ts +38 -27
  24. package/src/reconcile/index.ts +13 -4
  25. package/src/recurrence/index.ts +2 -1
  26. package/src/repeat/expiry.ts +2 -6
  27. package/src/repeat/index.ts +45 -1
  28. package/src/repeat/marker.ts +1 -22
  29. package/src/repeat/place.ts +74 -35
  30. package/src/schedule/index.ts +10 -4
  31. package/src/schedule/partition.ts +0 -19
  32. package/src/schedule/validate-identifier.ts +8 -6
  33. package/src/schedule/writer.ts +13 -4
  34. package/src/state/caps.ts +28 -3
  35. package/src/state/permission-history.ts +25 -0
  36. package/src/state/record.ts +110 -20
  37. package/src/testing/fake-engine.ts +17 -1
package/src/index.ts CHANGED
@@ -112,6 +112,15 @@ export type {
112
112
  /** The default engine. Consumers may substitute their own via the port above. */
113
113
  export { createNotifyKitEngine } from './engine/notify-kit-engine';
114
114
 
115
+ /**
116
+ * The whole package, assembled (0.6.0): scheduler, reconciler, recurrence,
117
+ * permissions, channels, interactions, snapshot and maintenance, wired the way the
118
+ * package means them to be. Start here; the factories below are for an app that
119
+ * needs a different wiring, and are also exported from `./advanced`.
120
+ */
121
+ export { createNotifications } from './notifications';
122
+ export type { Notifications, NotificationsDependencies } from './notifications';
123
+
115
124
  /** Initialization and the assembled runtime. */
116
125
  export { initialize } from './initialize';
117
126
  export type { RuntimeDependencies, NotificationRuntime } from './initialize';
@@ -0,0 +1,195 @@
1
+ /**
2
+ * One call that assembles the whole package (NOT-07).
3
+ *
4
+ * Until 0.5.0 an app wired a dozen factories itself — scheduler, reconciler,
5
+ * recurrence, permissions, channels, interactions, snapshot, maintenance steps
6
+ * — repeating the engine, tone resolver, clock and time-zone reader for each,
7
+ * and usually hard-coding `canScheduleExactly: () => true`. `createNotifications`
8
+ * does that wiring once, the way the package means it:
9
+ *
10
+ * - every part reports through the one redacting diagnostic emitter;
11
+ * - `canScheduleExactly` follows the exact-alarm permission the package last
12
+ * read, instead of a guess;
13
+ * - automatic maintenance has the standard steps registered (re-anchoring after
14
+ * a time-zone change, refreshing permissions, and refreshing recurrence
15
+ * windows when the app supplies its rules), so it does something on every
16
+ * return to the foreground.
17
+ *
18
+ * The individual factories stay exported for an app that needs a different
19
+ * wiring; they are also available from `@chipmobilesdk/rn-notification/advanced`.
20
+ */
21
+ import type { Channels } from './channel/migrate';
22
+ import { createToneResolver } from './channel/tone-map';
23
+ import type { DiagnosticCode, InitializeResult, NotificationConfig } from './contract';
24
+ import type { PermissionState } from './contract/permission';
25
+ import type { ReconcileResult } from './contract/reconcile';
26
+ import type { MaintenanceResult } from './contract/reconcile';
27
+ import type { RecurrenceRule } from './contract/recurrence';
28
+ import { createSnapshot, type Snapshot } from './diagnostics/snapshot';
29
+ import type { NotificationEngineAdapter } from './engine/adapter';
30
+ import { createNotifyKitEngine } from './engine/notify-kit-engine';
31
+ import { detectPlatform } from './engine/notify-kit/platform';
32
+ import { initialize, type NotificationRuntime } from './initialize';
33
+ import { createInteractions, type Interactions } from './interaction';
34
+ import { createPermissionRefreshStep } from './maintenance/permission-refresh';
35
+ import { createReAnchorStep } from './maintenance/reanchor';
36
+ import { createWindowRefreshStep } from './maintenance/window-refresh';
37
+ import { createPermissions, type Permissions } from './permission';
38
+ import type { PlatformInfo } from './permission/state';
39
+ import { createReconciler, type Reconciler } from './reconcile';
40
+ import { createRecurrence, type Recurrence } from './recurrence';
41
+ import { createScheduler, type Scheduler } from './schedule';
42
+ import { createPlatformTimeZoneReader, type TimeZoneReader } from './schedule/anchor';
43
+ import { createMmkvStore } from './state/mmkv-store';
44
+ import type { StateStore } from './state/store';
45
+
46
+ export interface NotificationsDependencies {
47
+ /** Defaults to the notify-kit engine. Inject a fake in tests. */
48
+ readonly engine?: NotificationEngineAdapter;
49
+ /** Defaults to MMKV-backed state. */
50
+ readonly store?: StateStore;
51
+ readonly timeZone?: TimeZoneReader;
52
+ readonly now?: () => number;
53
+ /** Defaults to the running OS and, on Android, its API level. */
54
+ readonly platform?: PlatformInfo;
55
+ /**
56
+ * The recurrence rules the app has registered. With it, maintenance refreshes
57
+ * their windows on every foreground return; the package never stores a rule
58
+ * itself, because a rule carries notification content (FR-096a).
59
+ */
60
+ readonly recurrenceRules?: () => readonly RecurrenceRule[];
61
+ /** Whether the app holds a push token, for the snapshot. Presence only. */
62
+ readonly hasPushToken?: () => boolean;
63
+ }
64
+
65
+ export interface Notifications {
66
+ readonly scheduler: Scheduler;
67
+ readonly reconciler: Reconciler;
68
+ readonly recurrence: Recurrence;
69
+ readonly permissions: Permissions;
70
+ readonly channels: Channels;
71
+ readonly interactions: Interactions;
72
+ readonly snapshot: Snapshot;
73
+ /** State, diagnostics and the maintenance controller `initialize` assembled. */
74
+ readonly runtime: NotificationRuntime;
75
+ /** What initialization did: tones applied, migrations run, how long it took. */
76
+ readonly initialization: InitializeResult;
77
+ /** The time-zone reader every part schedules against. */
78
+ readonly timeZone: TimeZoneReader;
79
+ /** Handles a tap or action; a shortcut for `interactions.onInteraction`. */
80
+ onInteraction: Interactions['onInteraction'];
81
+ /** Runs maintenance now, whatever the app state. */
82
+ runMaintenance(): Promise<MaintenanceResult>;
83
+ /** Whether the exact-alarm capability is granted, as last read. */
84
+ canScheduleExactly(): boolean;
85
+ /** Stops the lifecycle observer and every listener. */
86
+ dispose(): void;
87
+ }
88
+
89
+ /** Exact scheduling only on a positive answer; unknown degrades to approximate, never fails. */
90
+ function allowsExact(state: PermissionState | undefined): boolean {
91
+ return state === 'granted' || state === 'notRequired';
92
+ }
93
+
94
+ export async function createNotifications(
95
+ config: NotificationConfig,
96
+ deps: NotificationsDependencies = {},
97
+ ): Promise<Notifications> {
98
+ const engine = deps.engine ?? createNotifyKitEngine();
99
+ const now = deps.now ?? (() => Date.now());
100
+ const timeZone = deps.timeZone ?? createPlatformTimeZoneReader();
101
+ const { runtime, result } = await initialize(config, {
102
+ engine,
103
+ store: deps.store ?? createMmkvStore(),
104
+ now,
105
+ timeZone,
106
+ });
107
+ const { state } = runtime;
108
+ const emit = (code: string, message: string, context?: Record<string, unknown>) =>
109
+ runtime.emitter.emit(code as DiagnosticCode, message, context);
110
+ const resolveTone = createToneResolver(config.tones);
111
+
112
+ const permissions = createPermissions({
113
+ engine,
114
+ state,
115
+ platform: deps.platform ?? detectPlatform(),
116
+ now,
117
+ emit,
118
+ });
119
+ let exactAlarm: PermissionState | undefined;
120
+ const stopWatchingPermissions = permissions.onPermissionChange(report => {
121
+ exactAlarm = report.exactAlarm;
122
+ });
123
+ // Seeds the answer before anything is scheduled. A failed read leaves it
124
+ // unknown, which schedules in approximate mode rather than failing.
125
+ await permissions.getPermissions().catch(() => undefined);
126
+ const canScheduleExactly = () => allowsExact(exactAlarm);
127
+
128
+ const scheduling = { engine, resolveTone, timeZone, now, canScheduleExactly, emit };
129
+ const scheduler = createScheduler(scheduling);
130
+ const reconcilerCore = createReconciler(scheduling);
131
+ let lastReconcile: ReconcileResult | undefined;
132
+ const reconciler: Reconciler = {
133
+ ...reconcilerCore,
134
+ async reconcile(input) {
135
+ lastReconcile = await reconcilerCore.reconcile(input);
136
+ return lastReconcile;
137
+ },
138
+ };
139
+ const recurrence = createRecurrence({ ...scheduling, state, lookahead: runtime.lookahead });
140
+ const interactions = createInteractions({ engine, state, now, emit });
141
+
142
+ let lastExplicit: MaintenanceResult | undefined;
143
+ const snapshot = createSnapshot({
144
+ engine,
145
+ state,
146
+ now,
147
+ permissions: () => permissions.getPermissions(),
148
+ restrictions: () => permissions.getBackgroundRestrictions(),
149
+ appliedTones: () => runtime.channels.appliedVersions(),
150
+ lastReconcile: () => lastReconcile,
151
+ lastMaintenance: () => runtime.lastAutomaticMaintenance() ?? lastExplicit,
152
+ hasPushToken: deps.hasPushToken ?? (() => false),
153
+ });
154
+
155
+ const unregister = [
156
+ runtime.registry.register(
157
+ createReAnchorStep({ engine, timeZone, resolveTone, canScheduleExactly, emit }),
158
+ ),
159
+ runtime.registry.register(createPermissionRefreshStep({ permissions })),
160
+ ...(deps.recurrenceRules
161
+ ? [
162
+ runtime.registry.register(
163
+ createWindowRefreshStep({ recurrence, registeredRules: deps.recurrenceRules, emit }),
164
+ ),
165
+ ]
166
+ : []),
167
+ ];
168
+
169
+ return {
170
+ scheduler,
171
+ reconciler,
172
+ recurrence,
173
+ permissions,
174
+ channels: runtime.channels,
175
+ interactions,
176
+ snapshot,
177
+ runtime,
178
+ initialization: result,
179
+ timeZone,
180
+ onInteraction: handler => interactions.onInteraction(handler),
181
+ async runMaintenance() {
182
+ lastExplicit = await runtime.maintenance.runExplicit();
183
+ return lastExplicit;
184
+ },
185
+ canScheduleExactly,
186
+ dispose() {
187
+ stopWatchingPermissions();
188
+ for (const remove of unregister) {
189
+ remove();
190
+ }
191
+ interactions.dispose();
192
+ runtime.dispose();
193
+ },
194
+ };
195
+ }
@@ -17,7 +17,15 @@ import type { NotificationEngineAdapter } from '../engine/adapter';
17
17
  import type { StateManager } from '../state/record';
18
18
  import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
19
19
  import { capabilityFor, normalizeState, type PlatformInfo } from './state';
20
- import { hasRequested, recordRequested } from '../state/permission-history';
20
+ import { onForeground as onAppForeground } from '@chipmobilesdk/rn-core';
21
+
22
+ import {
23
+ clearDenials,
24
+ denialCount,
25
+ hasRequested,
26
+ recordDenial,
27
+ recordRequested,
28
+ } from '../state/permission-history';
21
29
 
22
30
  export interface PermissionDependencies {
23
31
  readonly engine: NotificationEngineAdapter;
@@ -25,6 +33,19 @@ export interface PermissionDependencies {
25
33
  readonly platform: PlatformInfo;
26
34
  readonly now: () => number;
27
35
  readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
36
+ /**
37
+ * Calls back when the app returns from the background. Defaults to rn-core's
38
+ * shared lifecycle; injected in tests.
39
+ */
40
+ readonly onForeground?: (listener: () => void) => () => void;
41
+ }
42
+
43
+ /**
44
+ * Refusals after which the system stops showing its prompt (NOT-12). Android 13+
45
+ * allows a second ask; iOS allows one.
46
+ */
47
+ function denialLimit(platform: PlatformInfo): number {
48
+ return platform.os === 'android' ? 2 : 1;
28
49
  }
29
50
 
30
51
  export type PermissionChangeHandler = (report: PermissionReport) => void;
@@ -34,6 +55,24 @@ export function createPermissions(deps: PermissionDependencies) {
34
55
  let listeners: PermissionChangeHandler[] = [];
35
56
  let lastReport: PermissionReport | undefined;
36
57
 
58
+ /**
59
+ * The engine's answer plus what only the package remembers. The platform
60
+ * reports a refusal as `denied` however often it happened; once the user has
61
+ * refused as many times as the system will ask, another request shows nothing,
62
+ * so the state is `permanentlyDenied` and the app should offer Settings.
63
+ */
64
+ const derive = (kind: PermissionKind, raw: PermissionState): PermissionState => {
65
+ const state = normalizeState(kind, raw, deps.platform);
66
+ if (kind !== 'display') return state;
67
+ if (state === 'granted' || state === 'provisional') {
68
+ clearDenials(deps.state, kind);
69
+ return state;
70
+ }
71
+ return state === 'denied' && denialCount(deps.state, kind) >= denialLimit(deps.platform)
72
+ ? 'permanentlyDenied'
73
+ : state;
74
+ };
75
+
37
76
  const read = async (): Promise<PermissionReport> => {
38
77
  // Never prompts. This is what lets a consumer decide the moment to ask.
39
78
  const [display, exactAlarm] = await Promise.all([
@@ -42,12 +81,28 @@ export function createPermissions(deps: PermissionDependencies) {
42
81
  ]);
43
82
 
44
83
  return {
45
- display: normalizeState('display', display, deps.platform),
46
- exactAlarm: normalizeState('exactAlarm', exactAlarm, deps.platform),
84
+ display: derive('display', display),
85
+ exactAlarm: derive('exactAlarm', exactAlarm),
47
86
  readAt: deps.now(),
48
87
  };
49
88
  };
50
89
 
90
+ const whenForeground = deps.onForeground ?? (listener => onAppForeground(listener));
91
+ let stopAwaitingSettings: (() => void) | undefined;
92
+ /**
93
+ * Exact alarms are granted on a system settings screen, so the answer read
94
+ * straight after opening it is always the old one. The real answer is read
95
+ * when the user comes back, and listeners hear it then (NOT-12).
96
+ */
97
+ const rereadOnReturn = (): void => {
98
+ stopAwaitingSettings?.();
99
+ stopAwaitingSettings = whenForeground(() => {
100
+ stopAwaitingSettings?.();
101
+ stopAwaitingSettings = undefined;
102
+ read().then(notifyIfChanged, () => undefined);
103
+ });
104
+ };
105
+
51
106
  const notifyIfChanged = (report: PermissionReport): boolean => {
52
107
  const changed =
53
108
  lastReport === undefined ||
@@ -79,7 +134,13 @@ export function createPermissions(deps: PermissionDependencies) {
79
134
  recordRequested(deps.state, kind, deps.now(), ownerTag);
80
135
 
81
136
  const raw = await deps.engine.requestPermission(kind);
82
- const result = normalizeState(kind, raw, deps.platform);
137
+ if (kind === 'display' && normalizeState(kind, raw, deps.platform) === 'denied') {
138
+ recordDenial(deps.state, kind, deps.now());
139
+ }
140
+ const result = derive(kind, raw);
141
+ if (capability.settingsOnly && result !== 'granted') {
142
+ rereadOnReturn();
143
+ }
83
144
 
84
145
  if (result === 'denied' || result === 'permanentlyDenied') {
85
146
  emit(
@@ -88,12 +88,22 @@ export function createFcmProvider(options: FcmProviderOptions = {}): PushProvide
88
88
  // the optional peer. Either way the local path is unaffected.
89
89
  if (instance === undefined) return 'unavailable';
90
90
 
91
+ // Asked without registering: fetching a token here would create an FCM
92
+ // registration (and an APNs one on iOS) just to answer a yes-or-no
93
+ // question, before the app asked for permission (NOT-24).
91
94
  try {
92
- await instance.getToken();
93
- return 'available';
95
+ if (instance.isDeviceRegisteredForRemoteMessages === true) {
96
+ return 'available';
97
+ }
98
+ if (instance.hasPermission !== undefined) {
99
+ // Messaging is present; whether it may deliver is the permission's
100
+ // question, and that is answered without registering anything.
101
+ await instance.hasPermission();
102
+ return 'available';
103
+ }
104
+ return 'unknown';
94
105
  } catch {
95
- // Registration can fail for reasons the SDK does not distinguish, so
96
- // `unknown` is reported rather than a guess at which one.
106
+ // The SDK does not say why, so `unknown` rather than a guess.
97
107
  return 'unknown';
98
108
  }
99
109
  },
package/src/push/gate.ts CHANGED
@@ -39,7 +39,8 @@ export function createPushGate(deps: PushGateDependencies) {
39
39
  const emit = deps.emit ?? (() => {});
40
40
  let handlers: PushHandler[] = [];
41
41
  let subscription: Unsubscribe | undefined;
42
- let tokenSubscription: Unsubscribe | undefined;
42
+ /** Every token subscription, so a second one does not leak the first (NOT-24). */
43
+ const tokenSubscriptions = new Set<Unsubscribe>();
43
44
  let deliveryChain: Promise<void> = Promise.resolve();
44
45
 
45
46
  const dispatch = (envelope: PushEnvelope): void => {
@@ -126,8 +127,13 @@ export function createPushGate(deps: PushGateDependencies) {
126
127
  },
127
128
 
128
129
  onTokenChange(handler: (token: string) => void): Unsubscribe {
129
- tokenSubscription = deps.provider.onTokenChange(handler);
130
- return tokenSubscription;
130
+ const unsubscribe = deps.provider.onTokenChange(handler);
131
+ tokenSubscriptions.add(unsubscribe);
132
+ return () => {
133
+ if (tokenSubscriptions.delete(unsubscribe)) {
134
+ unsubscribe();
135
+ }
136
+ };
131
137
  },
132
138
 
133
139
  async invalidateToken(): Promise<boolean> {
@@ -157,6 +163,20 @@ export function createPushGate(deps: PushGateDependencies) {
157
163
  };
158
164
  },
159
165
 
166
+ /**
167
+ * Processes a payload delivered while the app was in the background, from
168
+ * the module-scope handler `setBackgroundMessageHandler` requires (NOT-24).
169
+ *
170
+ * The same normalization and the same persisted deduplication as a
171
+ * foreground payload, so a redelivery after the process restarted is
172
+ * suppressed rather than handled twice. Resolves once every handler has run.
173
+ */
174
+ async handleBackground(raw: unknown): Promise<PushEnvelope | undefined> {
175
+ const envelope = receive(raw);
176
+ await deliveryChain;
177
+ return envelope;
178
+ },
179
+
160
180
  /** Processes the payload that launched the app, if any. */
161
181
  async collectInitial(): Promise<PushEnvelope | undefined> {
162
182
  const raw = await deps.provider.takeInitialMessage();
@@ -179,9 +199,11 @@ export function createPushGate(deps: PushGateDependencies) {
179
199
 
180
200
  dispose(): void {
181
201
  subscription?.();
182
- tokenSubscription?.();
183
202
  subscription = undefined;
184
- tokenSubscription = undefined;
203
+ for (const unsubscribe of tokenSubscriptions) {
204
+ unsubscribe();
205
+ }
206
+ tokenSubscriptions.clear();
185
207
  handlers = [];
186
208
  },
187
209
  };
@@ -31,42 +31,53 @@ export interface DiffResult {
31
31
  readonly unchanged: readonly string[];
32
32
  }
33
33
 
34
- /** Stable serialization of the fields that make a delivered notification different. */
35
- function fingerprint(request: NotificationRequest, resolvedAt: number): string {
34
+ /** The fields that make one delivered notification different from another. */
35
+ interface FingerprintFields {
36
+ readonly resolvedAt: number;
37
+ readonly anchoring: string;
38
+ readonly content: NotificationRequest['content'];
39
+ readonly tone: string;
40
+ readonly ownerTag?: string;
41
+ readonly groupTag?: string;
42
+ readonly routing?: NotificationRequest['routing'];
43
+ readonly actions?: NotificationRequest['actions'];
44
+ }
45
+
46
+ /**
47
+ * Stable serialization of those fields. One function for what the app wants and
48
+ * what the device holds, so the two cannot drift into comparing different
49
+ * things (NOT-19).
50
+ */
51
+ function fingerprintFields(fields: FingerprintFields): string {
36
52
  return JSON.stringify([
37
- resolvedAt,
38
- request.moment.anchoring ?? 'wallClock',
39
- request.content.title,
40
- request.content.body,
41
- request.content.subtitle ?? null,
42
- request.tone,
43
- request.ownerTag ?? null,
44
- request.groupTag ?? null,
53
+ fields.resolvedAt,
54
+ fields.anchoring,
55
+ fields.content.title,
56
+ fields.content.body,
57
+ fields.content.subtitle ?? null,
58
+ fields.tone,
59
+ fields.ownerTag ?? null,
60
+ fields.groupTag ?? null,
45
61
  // Key order is not stable across constructions, so sort before comparing —
46
62
  // otherwise two structurally identical payloads could compare unequal and
47
63
  // the second pass would rewrite every entry carrying routing data.
48
- request.routing === undefined ? null : stableStringify(request.routing),
49
- request.actions === undefined
64
+ fields.routing === undefined ? null : stableStringify(fields.routing),
65
+ fields.actions === undefined
50
66
  ? null
51
- : request.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
67
+ : fields.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
52
68
  ]);
53
69
  }
54
70
 
71
+ function fingerprint(request: NotificationRequest, resolvedAt: number): string {
72
+ return fingerprintFields({
73
+ ...request,
74
+ resolvedAt,
75
+ anchoring: request.moment.anchoring ?? 'wallClock',
76
+ });
77
+ }
78
+
55
79
  function entryFingerprint(entry: PendingEntry): string {
56
- return JSON.stringify([
57
- entry.resolvedAt,
58
- entry.anchoring,
59
- entry.content.title,
60
- entry.content.body,
61
- entry.content.subtitle ?? null,
62
- entry.tone,
63
- entry.ownerTag ?? null,
64
- entry.groupTag ?? null,
65
- entry.routing === undefined ? null : stableStringify(entry.routing),
66
- entry.actions === undefined
67
- ? null
68
- : entry.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
69
- ]);
80
+ return fingerprintFields(entry);
70
81
  }
71
82
 
72
83
  /** Key-order-independent JSON, so structurally equal payloads compare equal. */
@@ -59,7 +59,13 @@ export function createReconciler(deps: ReconcileDependencies) {
59
59
  const startedAt = deps.now();
60
60
  const zone = deps.timeZone();
61
61
 
62
- const device = (await deps.engine.getPending()).filter(entry => inDomain(entry, input));
62
+ const everything = await deps.engine.getPending();
63
+ const device = everything.filter(entry => inDomain(entry, input));
64
+ // The ceiling is the platform's, shared with every other domain, every
65
+ // recurring series and every directly scheduled notification. Budgeting a
66
+ // domain against the whole of it let two domains and a series overflow iOS's
67
+ // 64 together while each reported "nothing truncated" (NOT-11).
68
+ const heldElsewhere = everything.length - device.length;
63
69
  const existingIds = new Set(device.map(entry => entry.id));
64
70
 
65
71
  // Duplicates first: a repeated identifier is refused before anything else
@@ -94,10 +100,13 @@ export function createReconciler(deps: ReconcileDependencies) {
94
100
  // notifications can actually use is smaller than the platform's own.
95
101
  // Hiding that would let the package quietly reduce how many notifications
96
102
  // fit and report a full set as registered when it was not.
103
+ // One pass: the outstanding stop markers are read once for the capacity and
104
+ // every operation below, not once per operation (NOT-08).
105
+ const pass = writer.pass();
97
106
  const { kept, report: truncation } = truncateToCeiling(
98
107
  valid,
99
108
  resolvedAt,
100
- await writer.capacity(),
109
+ Math.max(0, (await pass.capacity()) - heldElsewhere),
101
110
  );
102
111
 
103
112
  if (truncation !== undefined) {
@@ -128,14 +137,14 @@ export function createReconciler(deps: ReconcileDependencies) {
128
137
  operations,
129
138
  async operation => {
130
139
  if (operation.kind === 'cancel') {
131
- await writer.cancel(operation.id);
140
+ await pass.cancel(operation.id);
132
141
  cancelled.push(operation.id);
133
142
  return;
134
143
  }
135
144
 
136
145
  const request = operation.request!;
137
146
  const tone = deps.resolveTone(request.tone)!;
138
- await writer.write({
147
+ await pass.write({
139
148
  request,
140
149
  resolvedAt: operation.resolvedAt!,
141
150
  tone,
@@ -248,7 +248,8 @@ export function createRecurrence(deps: RecurrenceDependencies) {
248
248
  const ids = pending.filter(entry => belongsTo(entry, seriesId)).map(entry => entry.id);
249
249
 
250
250
  await deps.engine.cancelMany(ids);
251
- for (const id of ids) await writer.releaseStop(id);
251
+ const pass = writer.pass();
252
+ for (const id of ids) await pass.releaseStop(id);
252
253
  removeSeries(deps.state, seriesId);
253
254
  return ids;
254
255
  },
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { DeliveryEvent, NotificationEngineAdapter } from '../engine/adapter';
13
+ import { createStopPlacement } from './place';
13
14
 
14
15
  export interface RepeatExpiryDependencies {
15
16
  readonly engine: NotificationEngineAdapter;
@@ -43,11 +44,6 @@ export function createRepeatExpiryHandler(deps: RepeatExpiryDependencies): Repea
43
44
  await deps.engine.cancel(stop.markerId);
44
45
  },
45
46
 
46
- async release(targetId: string): Promise<void> {
47
- const outstanding = await deps.engine.listRepeatStops();
48
- const marker = outstanding.find(stop => stop.targetId === targetId);
49
- if (marker === undefined) return;
50
- await deps.engine.cancel(marker.markerId);
51
- },
47
+ release: targetId => createStopPlacement(deps.engine).release(targetId),
52
48
  };
53
49
  }
@@ -8,7 +8,11 @@
8
8
  * An app that declares no repeating tone needs none of this.
9
9
  */
10
10
 
11
+ import type { InteractionEvent } from '../contract/interaction';
11
12
  import type { NotificationEngineAdapter } from '../engine/adapter';
13
+ import { buffer } from '../state/interaction-buffer';
14
+ import { StateManager } from '../state/record';
15
+ import type { StateStore } from '../state/store';
12
16
  import { createRepeatExpiryHandler } from './expiry';
13
17
 
14
18
  export { createRepeatExpiryHandler } from './expiry';
@@ -18,6 +22,22 @@ export { REPEAT_STOP_PREFIX, isRepeatStopId, markerIdFor, repeatStopFor } from '
18
22
  export interface BackgroundHandlerRegistration {
19
23
  readonly engine: NotificationEngineAdapter;
20
24
  readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
25
+ /**
26
+ * Handles a tap, action or dismissal that arrives while no part of the app is
27
+ * listening — typically a background action in a process the system started
28
+ * just for it (0.6.0). Runs inside the platform's background window, so keep
29
+ * it short. When the app's own `onInteraction` handlers are alive they get the
30
+ * event instead, with `appState: 'background'`.
31
+ */
32
+ readonly onInteraction?: (event: InteractionEvent) => Promise<void> | void;
33
+ /**
34
+ * Where to keep an interaction nobody handled, so `onInteraction` delivers it
35
+ * when the app next starts: the same store the runtime uses
36
+ * (`createMmkvStore()`). Used when `onInteraction` is absent or throws.
37
+ */
38
+ readonly store?: StateStore;
39
+ /** The clock for what is kept. Defaults to the system clock; injected in tests. */
40
+ readonly now?: () => number;
21
41
  }
22
42
 
23
43
  /**
@@ -37,7 +57,31 @@ export function registerBackgroundNotificationHandler(
37
57
  ): void {
38
58
  const handler = createRepeatExpiryHandler(registration);
39
59
 
40
- registration.engine.registerBackgroundDeliveryHandler(event => handler.handle(event));
60
+ const keep = (event: InteractionEvent): void => {
61
+ if (registration.store === undefined) return;
62
+ // A manager of its own: this runs in a process with no runtime, and the
63
+ // runtime created later loads what is written here.
64
+ buffer(
65
+ new StateManager({ store: registration.store, now: registration.now ?? (() => Date.now()) }),
66
+ event,
67
+ );
68
+ };
69
+
70
+ registration.engine.registerBackgroundDeliveryHandler(
71
+ event => handler.handle(event),
72
+ async event => {
73
+ if (registration.onInteraction === undefined) {
74
+ keep(event);
75
+ return;
76
+ }
77
+ try {
78
+ await registration.onInteraction(event);
79
+ } catch {
80
+ // The app's handler failed; the interaction is kept rather than lost.
81
+ keep(event);
82
+ }
83
+ },
84
+ );
41
85
 
42
86
  // Both paths, not just the background one. The engine routes a delivery to
43
87
  // the foreground listener while the app is alive and to the headless task
@@ -14,6 +14,7 @@
14
14
  * consumer identifier ends up colliding with an internal one.
15
15
  */
16
16
 
17
+ import { fnv1a, hex8 } from '../contract/hash';
17
18
  import type { RepeatStop } from '../engine/adapter';
18
19
 
19
20
  /**
@@ -25,28 +26,6 @@ import type { RepeatStop } from '../engine/adapter';
25
26
  */
26
27
  export const REPEAT_STOP_PREFIX = 'cms.stop:';
27
28
 
28
- /**
29
- * FNV-1a, 32-bit. Deterministic, dependency-free, and stable across releases.
30
- *
31
- * Bitwise operators are the algorithm, not a micro-optimisation: arithmetic
32
- * equivalents would not wrap to 32 bits, and a marker id that changed between
33
- * releases would orphan every marker already scheduled on a device.
34
- */
35
- /* eslint-disable no-bitwise */
36
- function fnv1a(value: string, basis: number): number {
37
- let hash = basis;
38
- for (let index = 0; index < value.length; index += 1) {
39
- hash ^= value.codePointAt(index) ?? 0;
40
- hash = Math.imul(hash, 0x01000193) >>> 0;
41
- }
42
- return hash >>> 0;
43
- }
44
- /* eslint-enable no-bitwise */
45
-
46
- function hex8(value: number): string {
47
- return value.toString(16).padStart(8, '0');
48
- }
49
-
50
29
  /**
51
30
  * The marker identifier for a target notification.
52
31
  *