@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
@@ -16,19 +16,35 @@
16
16
  * README. It is the app's manifest, so it is the app's declaration to make.
17
17
  */
18
18
 
19
- import { Platform } from 'react-native';
19
+ import {
20
+ createDiagnosticChannel,
21
+ currentPlatform,
22
+ lazyPeer,
23
+ openDurable,
24
+ type MmkvLike,
25
+ } from '@chipmobilesdk/rn-core';
26
+
27
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
20
28
  import type { StateStore } from './store';
21
- import { createMemoryStore } from './store';
22
29
 
23
30
  const INSTANCE_ID = 'chipmobilesdk.notification';
24
31
 
25
- /** The slice of a react-native-mmkv v4 instance this store uses (v4 names). */
26
- interface MmkvLike {
27
- getString(key: string): string | undefined;
28
- set(key: string, value: string): void;
29
- remove(key: string): unknown;
30
- clearAll(): void;
31
- getAllKeys(): string[];
32
+ const mmkv = lazyPeer('react-native-mmkv', () => {
33
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
34
+ try {
35
+ return require('react-native-mmkv') as {
36
+ createMMKV: (config: { id: string; path?: string }) => MmkvLike;
37
+ };
38
+ } catch (cause) {
39
+ throw cause;
40
+ }
41
+ });
42
+
43
+ const channel = createDiagnosticChannel('rn-notification');
44
+
45
+ export interface MmkvStoreOptions {
46
+ /** Told once, with the reason, when the store falls back to memory. */
47
+ readonly onDegraded?: (cause: unknown) => void;
32
48
  }
33
49
 
34
50
  /**
@@ -36,40 +52,41 @@ interface MmkvLike {
36
52
  *
37
53
  * The fallback is deliberate: a missing optional native module degrades
38
54
  * deduplication and cold-start buffering to session scope, which is a
39
- * documented degradation. It must never stop notifications being delivered.
55
+ * documented degradation. It must never stop notifications being delivered, and
56
+ * it is never silent (NOT-15): it reports `STATE_STORE_DEGRADED` to the app's SDK
57
+ * sinks and to `onDegraded`, and the store reports `durable: false`.
40
58
  */
41
- export function createMmkvStore(): StateStore {
42
- let instance: MmkvLike;
43
-
44
- try {
45
- // Required lazily so the package loads in environments without the native module.
46
- // v4's factory: up to 0.2.0 this called the v3 `new MMKV()`, which v4 — the
47
- // only major the peer range allows — no longer exports as a value, so every
48
- // device silently got the session-scoped memory store.
49
- const { createMMKV } = require('react-native-mmkv') as {
50
- createMMKV: (config: { id: string; path?: string }) => MmkvLike;
51
- };
52
-
53
- instance = createMMKV({
54
- id: INSTANCE_ID,
55
- // Caches is backup-excluded by platform design; see the note above.
56
- path: Platform.OS === 'ios' ? 'Library/Caches/chipmobilesdk-notification' : undefined,
57
- });
58
- } catch {
59
- return createMemoryStore();
60
- }
61
-
59
+ export function createMmkvStore(options: MmkvStoreOptions = {}): StateStore {
60
+ const port = openDurable(
61
+ () =>
62
+ // v4's factory: up to 0.2.0 this called the v3 `new MMKV()`, which v4 no
63
+ // longer exports as a value, so every device silently got memory.
64
+ mmkv
65
+ .require(peer => new Error(`${peer} is not installed`))
66
+ .createMMKV({
67
+ id: INSTANCE_ID,
68
+ // Caches is backup-excluded by platform design; see the note above.
69
+ ...(currentPlatform() === 'ios'
70
+ ? { path: 'Library/Caches/chipmobilesdk-notification' }
71
+ : {}),
72
+ }),
73
+ cause => {
74
+ channel.emit(DIAGNOSTIC_CODES.STATE_STORE_DEGRADED, 'warn');
75
+ options.onDegraded?.(cause);
76
+ },
77
+ );
62
78
  return {
63
- getString: key => instance.getString(key),
64
- setString: (key, value) => instance.set(key, value),
65
- delete: key => instance.remove(key),
66
- clearAll: () => instance.clearAll(),
79
+ getString: key => port.getString(key),
80
+ setString: (key, value) => port.set(key, value),
81
+ delete: key => port.remove(key),
82
+ clearAll: () => port.clearAll(),
67
83
  approximateBytes: () => {
68
84
  let total = 0;
69
- for (const key of instance.getAllKeys()) {
70
- total += key.length + (instance.getString(key)?.length ?? 0);
85
+ for (const key of port.keys()) {
86
+ total += key.length + (port.getString(key)?.length ?? 0);
71
87
  }
72
88
  return total;
73
89
  },
90
+ durable: port.durable,
74
91
  };
75
92
  }
@@ -39,3 +39,28 @@ export function recordRequested(
39
39
  { at: now, key: target, requested: true },
40
40
  ]);
41
41
  }
42
+
43
+ /** The device-wide key: a refusal is the user's answer to the app, not to one account. */
44
+ const DEVICE = '#device';
45
+
46
+ /** How many requests for `kind` the user has refused on this device. */
47
+ export function denialCount(state: StateManager, kind: PermissionKind): number {
48
+ const target = key(kind, DEVICE);
49
+ return state.read().permissionHistory.find(entry => entry.key === target)?.denials ?? 0;
50
+ }
51
+
52
+ export function recordDenial(state: StateManager, kind: PermissionKind, now: number): void {
53
+ const target = key(kind, DEVICE);
54
+ const denials = denialCount(state, kind) + 1;
55
+ state.update('permissionHistory', current => [
56
+ ...current.filter(entry => entry.key !== target),
57
+ { at: now, key: target, requested: true, denials },
58
+ ]);
59
+ }
60
+
61
+ /** Forgets refusals once the permission is seen granted: the next refusal starts over. */
62
+ export function clearDenials(state: StateManager, kind: PermissionKind): void {
63
+ if (denialCount(state, kind) === 0) return;
64
+ const target = key(kind, DEVICE);
65
+ state.update('permissionHistory', current => current.filter(entry => entry.key !== target));
66
+ }
@@ -16,7 +16,23 @@ import type { AnchoringMode, RoutingPayload } from '../contract/notification';
16
16
  import type { CollectionHealth, StateHealth } from '../contract/diagnostics';
17
17
  import { DIAGNOSTIC_CODES, type DiagnosticCode } from '../contract/errors';
18
18
 
19
+ /** The single record before 0.6.0; read once to migrate, then deleted. */
19
20
  const STATE_KEY = 'chipmobilesdk.notification.state';
21
+ /**
22
+ * Since 0.6.0 each collection has its own key, plus one for the schema version and
23
+ * summaries, so recording one dedup entry writes the dedup ledger and nothing else
24
+ * (NOT-16). Before, every update serialized the whole record — the 500-entry dedup
25
+ * ledger included — synchronously on the JS thread.
26
+ */
27
+ const META_KEY = `${STATE_KEY}:meta`;
28
+ const COLLECTIONS: readonly CollectionName[] = [
29
+ 'dedupLedger',
30
+ 'interactionBuffer',
31
+ 'migrationLedger',
32
+ 'seriesLedger',
33
+ 'permissionHistory',
34
+ ];
35
+ const keyFor = (collection: CollectionName) => `${STATE_KEY}:${collection}`;
20
36
 
21
37
  /**
22
38
  * Bumped whenever the shape below changes. A mismatch discards and rebuilds —
@@ -35,6 +51,10 @@ export interface BufferedInteraction extends Timestamped {
35
51
  readonly kind: 'tap' | 'action' | 'dismiss';
36
52
  readonly notificationId: string;
37
53
  readonly actionId?: string;
54
+ /**
55
+ * Only ever present in a record written before 0.5: typed replies are no
56
+ * longer persisted (interaction-buffer.ts).
57
+ */
38
58
  readonly inputText?: string;
39
59
  /** The one content-bearing thing persisted. Evicted the moment it is delivered. */
40
60
  readonly routing?: RoutingPayload;
@@ -76,6 +96,8 @@ export interface SeriesEntry extends Timestamped {
76
96
  export interface PermissionHistoryEntry extends Timestamped {
77
97
  readonly key: string;
78
98
  readonly requested: boolean;
99
+ /** Requests the user refused, so a permanent denial can be told apart (0.6.0). */
100
+ readonly denials?: number;
79
101
  }
80
102
 
81
103
  export interface StateRecord {
@@ -130,40 +152,77 @@ export class StateManager {
130
152
  }
131
153
 
132
154
  private load(): StateRecord {
133
- const raw = this.store.getString(STATE_KEY);
134
- if (raw === undefined) {
135
- return emptyRecord();
136
- }
137
-
138
155
  try {
139
- const parsed = JSON.parse(raw) as Partial<StateRecord>;
156
+ const meta = this.store.getString(META_KEY);
157
+ if (meta === undefined) {
158
+ return this.migrateLegacy();
159
+ }
160
+ const parsed = JSON.parse(meta) as Partial<StateRecord>;
140
161
  if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
141
162
  return this.discardAndRebuild('schema-version-mismatch', {
142
163
  found: parsed.schemaVersion,
143
164
  expected: STATE_SCHEMA_VERSION,
144
165
  });
145
166
  }
146
- return {
167
+ const record: Record<string, unknown> = {
147
168
  schemaVersion: STATE_SCHEMA_VERSION,
148
- dedupLedger: parsed.dedupLedger ?? [],
149
- interactionBuffer: parsed.interactionBuffer ?? [],
150
- migrationLedger: parsed.migrationLedger ?? [],
151
- seriesLedger: parsed.seriesLedger ?? [],
152
- permissionHistory: parsed.permissionHistory ?? [],
153
169
  lastReconcileSummary: parsed.lastReconcileSummary,
154
170
  lastMaintenanceSummary: parsed.lastMaintenanceSummary,
155
171
  };
172
+ for (const collection of COLLECTIONS) {
173
+ const raw = this.store.getString(keyFor(collection));
174
+ const entries: unknown = raw === undefined ? [] : JSON.parse(raw);
175
+ if (!Array.isArray(entries)) {
176
+ return this.discardAndRebuild('unparseable', { collection });
177
+ }
178
+ record[collection] = entries;
179
+ }
180
+ return record as unknown as StateRecord;
156
181
  } catch {
157
182
  return this.discardAndRebuild('unparseable');
158
183
  }
159
184
  }
160
185
 
186
+ /** Reads the pre-0.6.0 single record, writes it out per collection, and drops it. */
187
+ private migrateLegacy(): StateRecord {
188
+ const raw = this.store.getString(STATE_KEY);
189
+ if (raw === undefined) {
190
+ const fresh = emptyRecord();
191
+ this.persistMeta(fresh);
192
+ return fresh;
193
+ }
194
+ const parsed = JSON.parse(raw) as Partial<StateRecord>;
195
+ if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
196
+ return this.discardAndRebuild('schema-version-mismatch', {
197
+ found: parsed.schemaVersion,
198
+ expected: STATE_SCHEMA_VERSION,
199
+ });
200
+ }
201
+ this.record = {
202
+ schemaVersion: STATE_SCHEMA_VERSION,
203
+ dedupLedger: parsed.dedupLedger ?? [],
204
+ interactionBuffer: parsed.interactionBuffer ?? [],
205
+ migrationLedger: parsed.migrationLedger ?? [],
206
+ seriesLedger: parsed.seriesLedger ?? [],
207
+ permissionHistory: parsed.permissionHistory ?? [],
208
+ lastReconcileSummary: parsed.lastReconcileSummary,
209
+ lastMaintenanceSummary: parsed.lastMaintenanceSummary,
210
+ };
211
+ for (const collection of COLLECTIONS) {
212
+ this.persist(collection);
213
+ }
214
+ this.persistMeta(this.record);
215
+ this.store.delete(STATE_KEY);
216
+ return this.record;
217
+ }
218
+
161
219
  private discardAndRebuild(
162
220
  reason: string,
163
221
  extra?: Readonly<Record<string, unknown>>,
164
222
  ): StateRecord {
165
223
  this.rebuilt = true;
166
- this.store.delete(STATE_KEY);
224
+ this.deleteAll();
225
+ this.persistMeta(emptyRecord());
167
226
  this.emit({
168
227
  code: DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT,
169
228
  // Note what is actually lost, so the consequence is stated rather than implied.
@@ -172,10 +231,18 @@ export class StateManager {
172
231
  return emptyRecord();
173
232
  }
174
233
 
234
+ private deleteAll(): void {
235
+ this.store.delete(STATE_KEY);
236
+ this.store.delete(META_KEY);
237
+ for (const collection of COLLECTIONS) {
238
+ this.store.delete(keyFor(collection));
239
+ }
240
+ }
241
+
175
242
  /** Never throws. A storage failure degrades the affected guarantee, nothing more. */
176
- private persist(): void {
243
+ private write(key: string, value: unknown): void {
177
244
  try {
178
- this.store.setString(STATE_KEY, JSON.stringify(this.record));
245
+ this.store.setString(key, JSON.stringify(value));
179
246
  } catch (error) {
180
247
  this.emit({
181
248
  code: DIAGNOSTIC_CODES.STATE_WRITE_FAILED,
@@ -184,6 +251,20 @@ export class StateManager {
184
251
  }
185
252
  }
186
253
 
254
+ /** Writes one collection, and only that one. */
255
+ private persist(collection: CollectionName): void {
256
+ this.write(keyFor(collection), this.record[collection]);
257
+ }
258
+
259
+ /** The schema version and summaries. Present whenever any collection is stored. */
260
+ private persistMeta(record: StateRecord = this.record): void {
261
+ this.write(META_KEY, {
262
+ schemaVersion: STATE_SCHEMA_VERSION,
263
+ lastReconcileSummary: record.lastReconcileSummary,
264
+ lastMaintenanceSummary: record.lastMaintenanceSummary,
265
+ });
266
+ }
267
+
187
268
  read(): StateRecord {
188
269
  return this.record;
189
270
  }
@@ -194,7 +275,17 @@ export class StateManager {
194
275
  mutate: (current: StateRecord[K]) => StateRecord[K],
195
276
  ): void {
196
277
  const next = mutate(this.record[collection]);
197
- const { kept, evicted, reason } = evict(next as readonly Timestamped[], collection, this.now());
278
+ const { kept, evicted, reason, overCap } = evict(
279
+ next as readonly Timestamped[],
280
+ collection,
281
+ this.now(),
282
+ );
283
+ if (overCap > 0) {
284
+ this.emit({
285
+ code: DIAGNOSTIC_CODES.STATE_CAP_EXCEEDED,
286
+ context: { collection, overCap, cap: COLLECTION_CAPS[collection].maxEntries },
287
+ });
288
+ }
198
289
 
199
290
  if (evicted.length > 0) {
200
291
  this.emit({
@@ -207,7 +298,7 @@ export class StateManager {
207
298
  }
208
299
 
209
300
  this.record = { ...this.record, [collection]: kept } as StateRecord;
210
- this.persist();
301
+ this.persist(collection);
211
302
  }
212
303
 
213
304
  setSummary(
@@ -215,14 +306,15 @@ export class StateManager {
215
306
  value: Readonly<Record<string, unknown>>,
216
307
  ): void {
217
308
  this.record = { ...this.record, [key]: value };
218
- this.persist();
309
+ this.persistMeta();
219
310
  }
220
311
 
221
312
  /** Clears everything. Public through `resetState()`. */
222
313
  reset(): void {
223
314
  this.record = emptyRecord();
224
315
  this.rebuilt = false;
225
- this.store.delete(STATE_KEY);
316
+ this.deleteAll();
317
+ this.persistMeta();
226
318
  }
227
319
 
228
320
  /**
@@ -240,7 +332,9 @@ export class StateManager {
240
332
  e => !e.key.startsWith(`${ownerTag}:`),
241
333
  ),
242
334
  };
243
- this.persist();
335
+ this.persist('dedupLedger');
336
+ this.persist('interactionBuffer');
337
+ this.persist('permissionHistory');
244
338
  }
245
339
 
246
340
  health(): StateHealth {
@@ -13,6 +13,8 @@ export interface StateStore {
13
13
  clearAll(): void;
14
14
  /** Approximate on-disk size, for the health report. */
15
15
  approximateBytes(): number;
16
+ /** False when writes do not survive a restart (the memory fallback). */
17
+ readonly durable?: boolean;
16
18
  }
17
19
 
18
20
  /** In-memory store, used by tests and as the fallback when MMKV is unavailable. */
@@ -36,5 +38,6 @@ export function createMemoryStore(): StateStore {
36
38
  }
37
39
  return total;
38
40
  },
41
+ durable: false,
39
42
  };
40
43
  }
@@ -83,6 +83,11 @@ export interface FakeControls {
83
83
 
84
84
  /** Queues an interaction that arrived before any handler existed. */
85
85
  emitInteraction(event: InteractionEvent): void;
86
+ /**
87
+ * An interaction while the app is not in front: live `subscribeToInteractions`
88
+ * handlers get it, otherwise the registered background handler does.
89
+ */
90
+ emitBackgroundInteraction(event: InteractionEvent): Promise<void>;
86
91
  queueInitialInteraction(event: InteractionEvent): void;
87
92
 
88
93
  /** The labels a declared channel currently carries, so a label refresh is assertable. */
@@ -119,6 +124,7 @@ export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
119
124
  let listeners: ((event: InteractionEvent) => void)[] = [];
120
125
  let deliveryListeners: ((event: DeliveryEvent) => void)[] = [];
121
126
  let backgroundHandler: ((event: DeliveryEvent) => Promise<void>) | undefined;
127
+ let backgroundInteractionHandler: ((event: InteractionEvent) => Promise<void>) | undefined;
122
128
  let initialInteraction: InteractionEvent | undefined;
123
129
 
124
130
  let currentTime = options.now ?? Date.UTC(2026, 0, 1, 12, 0, 0);
@@ -313,8 +319,9 @@ export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
313
319
  };
314
320
  },
315
321
 
316
- registerBackgroundDeliveryHandler(handler) {
322
+ registerBackgroundDeliveryHandler(handler, onInteraction) {
317
323
  backgroundHandler = handler;
324
+ backgroundInteractionHandler = onInteraction;
318
325
  },
319
326
 
320
327
  hasBackgroundDeliveryHandler() {
@@ -385,6 +392,15 @@ export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
385
392
  }
386
393
  },
387
394
 
395
+ async emitBackgroundInteraction(event) {
396
+ const background: InteractionEvent = { ...event, appState: 'background' };
397
+ if (listeners.length > 0) {
398
+ for (const listener of [...listeners]) listener(background);
399
+ return;
400
+ }
401
+ await backgroundInteractionHandler?.(background);
402
+ },
403
+
388
404
  emitInteraction(event) {
389
405
  for (const listener of listeners) {
390
406
  listener(event);