@chipmobilesdk/rn-notification 0.4.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # @chipmobilesdk/rn-notification
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Privacy (NOT-14), on top of `NotificationError` now extending `SdkError` from the new
8
+ `@chipmobilesdk/rn-core` dependency: ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
9
+
10
+ - Text the user types into a reply action is no longer written to disk. It is held in
11
+ memory, so a handler registered later in the same process still receives
12
+ `inputText`; after process death the event arrives without it.
13
+ - A state snapshot reports pending entries with their content and routing values
14
+ reduced to their shape (`[string:12]`). Pass `verbose: () => true` to
15
+ `createSnapshot` to include the values while debugging.
16
+ - A `NotificationError`'s `context`, and every diagnostic context, also has token-,
17
+ URL- and e-mail-shaped strings removed under any key.
18
+
19
+ - One diagnostic stream: install a sink with `installSdkSink` from
20
+ `@chipmobilesdk/rn-core` and it receives what every package observes (events,
21
+ failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
22
+ rn-core, so a listener that throws no longer stops the others nor fails the call that
23
+ notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
24
+
25
+ - rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
26
+ events are no longer deduplicated away; the dedupe memory is bounded. A throwing
27
+ status, consent or placement listener is reported as an `internal_error` event.
28
+ - rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
29
+ with a stable `code`); without it, warnings go to `console.warn` in development
30
+ builds only. The warn-once memory is bounded.
31
+ - rn-chart: now depends on rn-core for its diagnostics.
32
+ - rn-payment: status, entitlement and pending listeners report a throw to the logger
33
+ instead of swallowing it.
34
+
35
+ - Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
36
+ and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
37
+
38
+ - rn-auth: creating `auth` no longer loads react-native-app-auth or
39
+ react-native-keychain; each loads on first use. New entrypoints
40
+ `@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
41
+ rn-core.
42
+ - rn-camera: a preview whose VisionCamera feed cannot load fails the session with
43
+ `cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
44
+ showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
45
+ - rn-notification: `createMmkvStore` reports a fallback to memory as
46
+ `STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
47
+ exposes `durable`.
48
+ - rn-payment: an iOS app validates its catalog against the iOS product ids; the
49
+ default platform used to be Android everywhere. A missing react-native-iap is
50
+ `PAYMENT_UNAVAILABLE`.
51
+ - rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
52
+ importing the package no longer loads Unistyles; `applyThemeMode` loads it on
53
+ first use.
54
+ - rn-ads, rn-local-db: the same loading, with the same error codes as before.
55
+
56
+ - Formatters come from rn-core's cache instead of being built per call. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
57
+
58
+ - rn-notification: resolving a time zone builds one `Intl.DateTimeFormat` per zone
59
+ instead of one per occurrence; expanding 32 series of 32 occurrences went from
60
+ about 230 ms to under 30 ms on Node. Zone validity is memoized.
61
+ - rn-i18n: number, currency and date formatters are cached. A bad option (an unknown
62
+ currency such as `usd1`) goes through the invalid-format policy instead of
63
+ throwing out of a render, and a locale's `defaultCurrency` must be a three-letter
64
+ ISO 4217 code at startup.
65
+
66
+ ### Patch Changes
67
+
68
+ - One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
69
+
70
+ - rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
71
+ reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
72
+ return from the background and `createManualLifecycle` in `./testing`.
73
+ - rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
74
+ subscribing to `AppState` each, so an app holds one native listener. The camera
75
+ controller's core layer no longer imports `react-native`.
76
+ - rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
77
+ `handleForeground()` on each return from the background. Off by default; calling
78
+ `handleForeground()` yourself works as before.
79
+ - rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
80
+ storage adapter has the new optional `getItemSync`, and returns false otherwise.
81
+ `localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
82
+ `KeyValuePort` (MMKV).
83
+ - Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
84
+ - @chipmobilesdk/rn-core@0.1.0
85
+
3
86
  ## 0.4.1
4
87
 
5
88
  ### Patch Changes
package/README.md CHANGED
@@ -312,6 +312,8 @@ notifications.onInteraction(async event => {
312
312
 
313
313
  One handler, identical shape in foreground, background, and cold start. Events that arrive before your handler registers are **buffered durably** — they survive process death, which is exactly what a cold-start tap is.
314
314
 
315
+ Text the user typed into a reply action (`inputText`) is the exception: it is free text, so it is **never written to disk**. It is held in memory beside the buffer, so a handler that registers later in the same process still receives it; if the process dies first, the event is delivered without `inputText`.
316
+
315
317
  When the target is gone, call `reportTargetMissing()` and pick your own fallback. No platform error is ever shown to the user.
316
318
 
317
319
  ## Push
@@ -353,7 +355,7 @@ Six collections, in app-private, backup-excluded storage:
353
355
  | Permission history | 64 | — |
354
356
  | Last reconcile summary | 1 | — |
355
357
 
356
- **No notification content, no push token, no desired set.** The interaction buffer is the sole content-bearing exception, and it evicts on delivery.
358
+ **No notification content, no push token, no desired set, no typed reply.** The interaction buffer is the sole content-bearing exception (its routing payload), and it evicts on delivery.
357
359
 
358
360
  Corrupt state is **discarded and rebuilt**, never blocking. What you lose: deduplication history and buffered interactions. What is unaffected: **every notification already scheduled with the OS**.
359
361
 
@@ -404,7 +406,11 @@ Delivery accuracy: within **60 s** with exact alarms; within **15 min** without,
404
406
 
405
407
  ## Diagnostic identifiers
406
408
 
407
- 43 stable identifiers across validation, reconciliation, permissions, channels, interaction, push, state, and volume. Every one is reachable through the fault injector. See `ALL_DIAGNOSTIC_CODES`.
409
+ `NotificationError` extends `SdkError` from `@chipmobilesdk/rn-core` (`isSdkError`, `error.pkg === 'rn-notification'`), and its `context` is redacted when the error is built: content keys become their shape, and other strings lose anything shaped like a token, URL or e-mail address.
410
+
411
+ The state snapshot (`createSnapshot`) reports the pending set entry by entry, but a snapshot is what gets pasted into a bug report, so each entry's content and routing values are reduced to their shape (`[string:12]`). Pass `verbose: () => true` to include the values while debugging.
412
+
413
+ 45 stable identifiers across validation, reconciliation, permissions, channels, interaction, push, state, and volume. Every one is reachable through the fault injector. See `ALL_DIAGNOSTIC_CODES`.
408
414
 
409
415
  Some are **reported outcomes, not failures** — the operation succeeded and something you should know happened alongside it: `TRUNCATED_OVER_CEILING`, `EXACT_ALARM_UNAVAILABLE`, `TARGET_MISSING`, `PENDING_SET_DISCREPANCY`, `BURST_THRESHOLD_EXCEEDED`, `PAYLOAD_VERSION_SKEW`, `PAYLOAD_DUPLICATE`, `STATE_CAP_EVICTED`, `STATE_CORRUPT_REBUILT`, `DND_BYPASS_UNAVAILABLE`, `TONE_LABELS_REFRESHED`, `REPEAT_ALERT_UNAVAILABLE`, `REPEAT_STOP_HANDLER_MISSING`. Use `isNonFailure(code)`.
410
416
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-notification",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -31,9 +31,12 @@
31
31
  ],
32
32
  "sideEffects": false,
33
33
  "scripts": {
34
- "typecheck": "tsc -p tsconfig.json --noEmit",
34
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
35
35
  "pack:check": "npm pack --dry-run"
36
36
  },
37
+ "dependencies": {
38
+ "@chipmobilesdk/rn-core": "^0.1.0"
39
+ },
37
40
  "peerDependencies": {
38
41
  "@react-native-firebase/app": ">=26.0.0",
39
42
  "@react-native-firebase/messaging": ">=26.0.0",
@@ -11,6 +11,10 @@
11
11
  * See `specs/009-rn-notification-package/contracts/errors.md`.
12
12
  */
13
13
 
14
+ import { SdkError } from '@chipmobilesdk/rn-core';
15
+
16
+ import { redactContext } from '../diagnostics/redact';
17
+
14
18
  export const DIAGNOSTIC_CODES = {
15
19
  // --- Validation: raised before any platform call -------------------------
16
20
  INVALID_ID_EMPTY: 'INVALID_ID_EMPTY',
@@ -66,6 +70,8 @@ export const DIAGNOSTIC_CODES = {
66
70
  // --- Persisted state -----------------------------------------------------
67
71
  STATE_CORRUPT_REBUILT: 'STATE_CORRUPT_REBUILT',
68
72
  STATE_WRITE_FAILED: 'STATE_WRITE_FAILED',
73
+ /** The durable store could not open; state lasts for this session only. */
74
+ STATE_STORE_DEGRADED: 'STATE_STORE_DEGRADED',
69
75
  STATE_CAP_EVICTED: 'STATE_CAP_EVICTED',
70
76
 
71
77
  // --- Volume --------------------------------------------------------------
@@ -91,16 +97,18 @@ export const ALL_DIAGNOSTIC_CODES: readonly DiagnosticCode[] = Object.freeze(
91
97
  * a rule, the *shape* of the violation is reported — a length, a character
92
98
  * position — rather than the value itself.
93
99
  */
94
- export class NotificationError extends Error {
95
- readonly code: DiagnosticCode;
100
+ export class NotificationError extends SdkError<DiagnosticCode> {
96
101
  readonly context?: Readonly<Record<string, unknown>>;
97
102
 
98
103
  constructor(code: DiagnosticCode, message: string, context?: Readonly<Record<string, unknown>>) {
99
- super(message);
104
+ super('rn-notification', code, { message });
100
105
  this.name = 'NotificationError';
101
- this.code = code;
102
- this.context = context;
103
- Object.setPrototypeOf(this, NotificationError.prototype);
106
+ // Redacted on the way in: an error object reaches crash reporters, which
107
+ // never run with verbose diagnostics.
108
+ const redacted = redactContext(context, false);
109
+ if (redacted !== undefined) {
110
+ this.context = redacted;
111
+ }
104
112
  }
105
113
  }
106
114
 
@@ -7,6 +7,8 @@
7
7
  * or stays silent.
8
8
  */
9
9
 
10
+ import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
11
+
10
12
  import type { DiagnosticEvent, DiagnosticSeverity, EventSink } from '../contract/diagnostics';
11
13
  import type { DiagnosticCode } from '../contract/errors';
12
14
  import { isNonFailure } from '../contract/errors';
@@ -18,6 +20,8 @@ export interface EmitterOptions {
18
20
  readonly now?: () => number;
19
21
  }
20
22
 
23
+ const channel = createDiagnosticChannel<DiagnosticCode>('rn-notification');
24
+
21
25
  export class DiagnosticEmitter {
22
26
  private sink?: EventSink;
23
27
  private verbose: boolean;
@@ -47,12 +51,17 @@ export class DiagnosticEmitter {
47
51
  context?: Readonly<Record<string, unknown>>,
48
52
  severity?: DiagnosticSeverity,
49
53
  ): void {
54
+ const resolvedSeverity = severity ?? (isNonFailure(code) ? 'info' : 'error');
55
+ // The app's SDK sinks (`installSdkSink`, CORE-08) never get content, even
56
+ // with verbose diagnostics on: verbose is for this package's own sink.
57
+ channel.emit(code, resolvedSeverity, redactContext(context, false));
58
+
50
59
  // No sink means no work at all — not a buffer that grows until one appears.
51
60
  if (this.sink === undefined) return;
52
61
 
53
62
  const event: DiagnosticEvent = {
54
63
  code,
55
- severity: severity ?? (isNonFailure(code) ? 'info' : 'error'),
64
+ severity: resolvedSeverity,
56
65
  message,
57
66
  at: this.now(),
58
67
  context: redactContext(context, this.verbose),
@@ -10,6 +10,8 @@
10
10
  * — which is what actually helps someone debug a validation failure anyway.
11
11
  */
12
12
 
13
+ import { createRedactor } from '@chipmobilesdk/rn-core';
14
+
13
15
  /** Keys whose values are never emitted at any verbosity below verbose. */
14
16
  const REDACTED_KEYS = new Set([
15
17
  'title',
@@ -26,17 +28,12 @@ const REDACTED_KEYS = new Set([
26
28
  'data',
27
29
  ]);
28
30
 
29
- const REDACTED = '[redacted]';
30
-
31
- function describe(value: unknown): string {
32
- if (value === null) return '[null]';
33
- if (value === undefined) return '[undefined]';
34
- if (typeof value === 'string') return `[string:${value.length}]`;
35
- if (typeof value === 'number' || typeof value === 'boolean') return REDACTED;
36
- if (Array.isArray(value)) return `[array:${value.length}]`;
37
- if (typeof value === 'object') return `[object:${Object.keys(value).length}]`;
38
- return REDACTED;
39
- }
31
+ /**
32
+ * rn-core's redactor: the keys above are replaced by their shape (a length, a
33
+ * type), and every other string is pattern-redacted, so a token or an e-mail
34
+ * address under an innocent key such as `reason` does not get through either.
35
+ */
36
+ const redactor = createRedactor({ shapeKeys: REDACTED_KEYS });
40
37
 
41
38
  /**
42
39
  * Strips content values from a diagnostic context.
@@ -49,21 +46,7 @@ export function redactContext(
49
46
  context: Readonly<Record<string, unknown>> | undefined,
50
47
  verbose: boolean,
51
48
  ): Readonly<Record<string, unknown>> | undefined {
52
- if (context === undefined) return undefined;
53
- if (verbose) return context;
54
-
55
- const walk = (value: unknown, depth: number): unknown => {
56
- if (depth > 6 || value === null || typeof value !== 'object') return value;
57
- if (Array.isArray(value)) return value.map(item => walk(item, depth + 1));
58
-
59
- const output: Record<string, unknown> = {};
60
- for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
61
- output[key] = REDACTED_KEYS.has(key) ? describe(inner) : walk(inner, depth + 1);
62
- }
63
- return output;
64
- };
65
-
66
- return walk(context, 0) as Readonly<Record<string, unknown>>;
49
+ return verbose ? context : redactor.context(context);
67
50
  }
68
51
 
69
52
  /** True when a key would be redacted. Exported so tests can assert the policy directly. */
@@ -9,6 +9,8 @@
9
9
  * into a bug report.
10
10
  */
11
11
 
12
+ import { describeShape } from '@chipmobilesdk/rn-core';
13
+
12
14
  import type { StateSnapshot } from '../contract/diagnostics';
13
15
  import type { PendingEntry } from '../contract/notification';
14
16
  import type { PermissionReport, RestrictionReport } from '../contract/permission';
@@ -30,6 +32,35 @@ export interface SnapshotDependencies {
30
32
  readonly lastMaintenance: () => MaintenanceResult | undefined;
31
33
  /** Presence only — never the value. */
32
34
  readonly hasPushToken: () => boolean;
35
+ /**
36
+ * Whether verbose diagnostics are on. Off (the default), pending entries carry
37
+ * the shape of their content and routing (`[string:12]`), not the values.
38
+ */
39
+ readonly verbose?: () => boolean;
40
+ }
41
+
42
+ /**
43
+ * A pending entry with content replaced by its shape. A snapshot is the thing
44
+ * most likely to be pasted into a bug report, so it never carries notification
45
+ * text or routing values unless verbose diagnostics is on (NOT-14).
46
+ */
47
+ function withoutContent(entry: PendingEntry): PendingEntry {
48
+ const { content, routing } = entry;
49
+ return {
50
+ ...entry,
51
+ content: {
52
+ title: describeShape(content.title),
53
+ body: describeShape(content.body),
54
+ ...(content.subtitle === undefined ? {} : { subtitle: describeShape(content.subtitle) }),
55
+ },
56
+ ...(routing === undefined
57
+ ? {}
58
+ : {
59
+ routing: Object.fromEntries(
60
+ Object.entries(routing).map(([key, value]) => [key, describeShape(value)]),
61
+ ),
62
+ }),
63
+ };
33
64
  }
34
65
 
35
66
  export function createSnapshot(deps: SnapshotDependencies) {
@@ -52,7 +83,9 @@ export function createSnapshot(deps: SnapshotDependencies) {
52
83
  appliedVersion: tone.appliedVersion,
53
84
  })),
54
85
  pendingCount: pending.length,
55
- pending: pending as readonly PendingEntry[],
86
+ pending: deps.verbose?.()
87
+ ? (pending as readonly PendingEntry[])
88
+ : pending.map(entry => withoutContent(entry as PendingEntry)),
56
89
  lastReconcile: deps.lastReconcile(),
57
90
  lastMaintenance: maintenance,
58
91
  discrepancy,
@@ -11,6 +11,8 @@
11
11
  * the normal diagnostic path rather than crashing at import time.
12
12
  */
13
13
 
14
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
15
+
14
16
  import { Platform } from 'react-native';
15
17
  import type {
16
18
  NotificationEngineAdapter,
@@ -164,17 +166,24 @@ function compact<T extends Record<string, unknown>>(value: T): Partial<T> {
164
166
  return output as Partial<T>;
165
167
  }
166
168
 
167
- function loadEngine(): NotifyKitModule {
169
+ const notifyKit = lazyPeer('react-native-notify-kit', () => {
170
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
168
171
  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
- );
172
+ return require('react-native-notify-kit') as { default: NotifyKitModule };
173
+ } catch (cause) {
174
+ throw cause;
177
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;
178
187
  }
179
188
 
180
189
  /**
package/src/initialize.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * only ran when the app invoked it by hand.
16
16
  */
17
17
 
18
- import { AppState } from 'react-native';
18
+ import { appLifecycle } from '@chipmobilesdk/rn-core';
19
19
  import type { InitializeResult, NotificationConfig } from './contract/diagnostics';
20
20
  import { DEFAULT_LOOKAHEAD, LOOKAHEAD_BOUNDS, type LookaheadConfig } from './contract/recurrence';
21
21
  import type { MaintenanceResult } from './contract/reconcile';
@@ -35,11 +35,18 @@ export interface AppStateSource {
35
35
  addEventListener(type: 'change', listener: (state: string) => void): { remove(): void };
36
36
  }
37
37
 
38
+ /** rn-core's shared lifecycle port in the observer's shape: one native subscription for every package (PERF-07). */
39
+ const sharedLifecycle: AppStateSource = {
40
+ addEventListener: (_type, listener) => ({
41
+ remove: appLifecycle().subscribe(state => listener(state)),
42
+ }),
43
+ };
44
+
38
45
  export interface RuntimeDependencies {
39
46
  readonly engine: NotificationEngineAdapter;
40
47
  readonly store: StateStore;
41
48
  readonly now?: () => number;
42
- /** Injected in tests; defaults to React Native's `AppState`. */
49
+ /** Injected in tests; defaults to rn-core's shared `appLifecycle()`. */
43
50
  readonly appState?: AppStateSource;
44
51
  /** Injected in tests; defaults to the platform zone reader. */
45
52
  readonly timeZone?: TimeZoneReader;
@@ -196,7 +203,7 @@ export async function initialize(
196
203
  },
197
204
  );
198
205
  };
199
- const subscription = (deps.appState ?? AppState).addEventListener('change', onAppState);
206
+ const subscription = (deps.appState ?? sharedLifecycle).addEventListener('change', onAppState);
200
207
 
201
208
  const runtime: NotificationRuntime = {
202
209
  engine: deps.engine,
@@ -11,6 +11,8 @@
11
11
  * not install.
12
12
  */
13
13
 
14
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
15
+
14
16
  import type { PushAvailability, PushProviderAdapter } from './provider-adapter';
15
17
  import type { Unsubscribe } from '../contract/interaction';
16
18
 
@@ -31,15 +33,20 @@ interface MessagingInstance {
31
33
  isDeviceRegisteredForRemoteMessages?: boolean;
32
34
  }
33
35
 
34
- function loadMessaging(): MessagingModule | undefined {
36
+ const messagingPeer = lazyPeer('@react-native-firebase/messaging', () => {
37
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
35
38
  try {
36
- const module = require('@react-native-firebase/messaging') as { default: MessagingModule };
37
- return module.default;
38
- } catch {
39
- // Absent optional peer, or missing native module. Reported as
40
- // unavailability through the normal path rather than thrown at import.
41
- return undefined;
39
+ return require('@react-native-firebase/messaging') as { default: MessagingModule };
40
+ } catch (cause) {
41
+ throw cause;
42
42
  }
43
+ });
44
+
45
+ function loadMessaging(): MessagingModule | undefined {
46
+ // Absent optional peer, or missing native module: reported as unavailability
47
+ // through the normal path rather than thrown at import.
48
+ const result = messagingPeer.get();
49
+ return result.ok ? result.module.default : undefined;
43
50
  }
44
51
 
45
52
  export interface FcmProviderOptions {
@@ -16,12 +16,27 @@
16
16
  * each entry is what makes that detectable instead of invisible.
17
17
  */
18
18
 
19
+ import {
20
+ getDateTimeFormat,
21
+ isValidTimeZone as isZoneValid,
22
+ lazyPeer,
23
+ } from '@chipmobilesdk/rn-core';
24
+
19
25
  import type { AnchoringMode, PendingEntry } from '../contract/notification';
20
26
  import { DEFAULT_ANCHORING_MODE } from '../contract/notification';
21
27
 
22
28
  /** Reads the platform time zone. Injected so the pure logic needs no native module. */
23
29
  export type TimeZoneReader = () => string;
24
30
 
31
+ const localizePeer = lazyPeer('react-native-localize', () => {
32
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
33
+ try {
34
+ return require('react-native-localize') as { getTimeZone(): string };
35
+ } catch (cause) {
36
+ throw cause;
37
+ }
38
+ });
39
+
25
40
  /**
26
41
  * Resolves the platform zone via `react-native-localize`.
27
42
  *
@@ -32,9 +47,10 @@ export type TimeZoneReader = () => string;
32
47
  */
33
48
  export function createPlatformTimeZoneReader(): TimeZoneReader {
34
49
  return () => {
50
+ const localize = localizePeer.get();
35
51
  try {
36
- const localize = require('react-native-localize') as { getTimeZone(): string };
37
- return localize.getTimeZone();
52
+ if (localize.ok) return localize.module.getTimeZone();
53
+ return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
38
54
  } catch {
39
55
  try {
40
56
  return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
@@ -65,15 +81,22 @@ export interface WallClockParts {
65
81
  * tone id here — so it must be checked before it reaches `Intl`.
66
82
  */
67
83
  export function isValidTimeZone(timeZone: string): boolean {
68
- if (timeZone.length === 0) return false;
69
- try {
70
- new Intl.DateTimeFormat('en-US', { timeZone }).format(0);
71
- return true;
72
- } catch {
73
- return false;
74
- }
84
+ // rn-core memoizes the answer per zone: a recurrence expansion asks thousands
85
+ // of times about the same few zones (PERF-01).
86
+ return isZoneValid(timeZone);
75
87
  }
76
88
 
89
+ /** The options `partsInZone` formats with; one shared formatter per zone. */
90
+ const PARTS_OPTIONS = {
91
+ year: 'numeric',
92
+ month: '2-digit',
93
+ day: '2-digit',
94
+ hour: '2-digit',
95
+ minute: '2-digit',
96
+ second: '2-digit',
97
+ hour12: false,
98
+ } as const;
99
+
77
100
  /**
78
101
  * Decomposes an instant into the clock reading it produces in a given zone.
79
102
  *
@@ -82,16 +105,9 @@ export function isValidTimeZone(timeZone: string): boolean {
82
105
  */
83
106
  export function partsInZone(instant: number, timeZone: string): WallClockParts {
84
107
  const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC';
85
- const formatter = new Intl.DateTimeFormat('en-US', {
86
- timeZone: zone,
87
- year: 'numeric',
88
- month: '2-digit',
89
- day: '2-digit',
90
- hour: '2-digit',
91
- minute: '2-digit',
92
- second: '2-digit',
93
- hour12: false,
94
- });
108
+ // Built once per zone and reused (PERF-01): constructing one per call was
109
+ // most of the cost of expanding a recurrence.
110
+ const formatter = getDateTimeFormat('en-US', { ...PARTS_OPTIONS, timeZone: zone });
95
111
 
96
112
  const fields: Record<string, number> = {};
97
113
  for (const part of formatter.formatToParts(new Date(instant))) {
@@ -9,11 +9,28 @@
9
9
  * cannot be delivered without its routing payload. It is evicted the moment it
10
10
  * is delivered, and time-limited regardless, so an event nobody ever collects
11
11
  * cannot sit on disk forever.
12
+ *
13
+ * Text the user typed into a reply action is never written to disk (NOT-14): it
14
+ * is free text, and the store is not encrypted. It is held in memory beside the
15
+ * buffer, so a handler registered later in the same process still receives it;
16
+ * if the process dies first, the event is delivered without `inputText`.
12
17
  */
13
18
 
14
19
  import type { StateManager, BufferedInteraction } from './record';
15
20
  import type { InteractionEvent } from '../contract/interaction';
16
21
 
22
+ /** Typed replies, in memory only, per state manager and buffered event id. */
23
+ const typedReplies = new WeakMap<StateManager, Map<string, string>>();
24
+
25
+ function repliesOf(state: StateManager): Map<string, string> {
26
+ let replies = typedReplies.get(state);
27
+ if (!replies) {
28
+ replies = new Map();
29
+ typedReplies.set(state, replies);
30
+ }
31
+ return replies;
32
+ }
33
+
17
34
  /** Identity for a buffered event, derived so re-buffering cannot duplicate. */
18
35
  function bufferedId(event: InteractionEvent): string {
19
36
  return `${event.kind}:${event.notificationId}:${event.actionId ?? ''}:${event.occurredAt}`;
@@ -21,6 +38,9 @@ function bufferedId(event: InteractionEvent): string {
21
38
 
22
39
  export function buffer(state: StateManager, event: InteractionEvent): void {
23
40
  const id = bufferedId(event);
41
+ if (event.inputText !== undefined) {
42
+ repliesOf(state).set(id, event.inputText);
43
+ }
24
44
 
25
45
  state.update('interactionBuffer', current => {
26
46
  if (current.some(entry => entry.id === id)) return current;
@@ -31,7 +51,6 @@ export function buffer(state: StateManager, event: InteractionEvent): void {
31
51
  kind: event.kind,
32
52
  notificationId: event.notificationId,
33
53
  actionId: event.actionId,
34
- inputText: event.inputText,
35
54
  routing: event.routing,
36
55
  ownerTag: event.ownerTag,
37
56
  };
@@ -52,12 +71,15 @@ export function drain(state: StateManager): readonly InteractionEvent[] {
52
71
  if (buffered.length === 0) return [];
53
72
 
54
73
  state.update('interactionBuffer', () => []);
74
+ const replies = repliesOf(state);
55
75
 
56
76
  return buffered.map(entry => ({
57
77
  kind: entry.kind,
58
78
  notificationId: entry.notificationId,
59
79
  actionId: entry.actionId,
60
- inputText: entry.inputText,
80
+ // A record written before 0.5 may still carry the text on disk; it is
81
+ // delivered once, and the buffer was just cleared.
82
+ inputText: takeReply(replies, entry.id) ?? entry.inputText,
61
83
  routing: entry.routing,
62
84
  ownerTag: entry.ownerTag,
63
85
  occurredAt: entry.at,
@@ -71,3 +93,9 @@ export function drain(state: StateManager): readonly InteractionEvent[] {
71
93
  export function bufferedCount(state: StateManager): number {
72
94
  return state.read().interactionBuffer.length;
73
95
  }
96
+
97
+ function takeReply(replies: Map<string, string>, id: string): string | undefined {
98
+ const text = replies.get(id);
99
+ replies.delete(id);
100
+ return text;
101
+ }
@@ -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
  }
@@ -35,6 +35,10 @@ export interface BufferedInteraction extends Timestamped {
35
35
  readonly kind: 'tap' | 'action' | 'dismiss';
36
36
  readonly notificationId: string;
37
37
  readonly actionId?: string;
38
+ /**
39
+ * Only ever present in a record written before 0.5: typed replies are no
40
+ * longer persisted (interaction-buffer.ts).
41
+ */
38
42
  readonly inputText?: string;
39
43
  /** The one content-bearing thing persisted. Evicted the moment it is delivered. */
40
44
  readonly routing?: RoutingPayload;
@@ -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
  }