@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 +83 -0
- package/README.md +8 -2
- package/package.json +5 -2
- package/src/contract/errors.ts +14 -6
- package/src/diagnostics/events.ts +10 -1
- package/src/diagnostics/redact.ts +9 -26
- package/src/diagnostics/snapshot.ts +34 -1
- package/src/engine/notify-kit-engine.ts +18 -9
- package/src/initialize.ts +10 -3
- package/src/push/fcm-provider.ts +14 -7
- package/src/schedule/anchor.ts +35 -19
- package/src/state/interaction-buffer.ts +30 -2
- package/src/state/mmkv-store.ts +54 -37
- package/src/state/record.ts +4 -0
- package/src/state/store.ts +3 -0
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
|
-
|
|
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.
|
|
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
|
|
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",
|
package/src/contract/errors.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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:
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 {
|
|
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
|
|
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 ??
|
|
206
|
+
const subscription = (deps.appState ?? sharedLifecycle).addEventListener('change', onAppState);
|
|
200
207
|
|
|
201
208
|
const runtime: NotificationRuntime = {
|
|
202
209
|
engine: deps.engine,
|
package/src/push/fcm-provider.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 {
|
package/src/schedule/anchor.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
return
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/state/mmkv-store.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =>
|
|
64
|
-
setString: (key, value) =>
|
|
65
|
-
delete: key =>
|
|
66
|
-
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
|
|
70
|
-
total += key.length + (
|
|
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
|
}
|
package/src/state/record.ts
CHANGED
|
@@ -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;
|
package/src/state/store.ts
CHANGED
|
@@ -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
|
}
|