@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.
- package/CHANGELOG.md +156 -0
- package/README.md +108 -12
- package/package.json +10 -2
- package/skills/sdk-rn-notification/SKILL.md +6 -5
- package/src/advanced.ts +35 -0
- package/src/channel/migrate.ts +176 -158
- package/src/channel/tone-map.ts +2 -10
- package/src/contract/errors.ts +21 -6
- package/src/contract/hash.ts +26 -0
- package/src/diagnostics/events.ts +10 -1
- package/src/diagnostics/redact.ts +9 -26
- package/src/diagnostics/snapshot.ts +34 -1
- package/src/engine/adapter.ts +21 -1
- package/src/engine/notify-kit/events.ts +155 -0
- package/src/engine/notify-kit/module.ts +148 -0
- package/src/engine/notify-kit/platform/android.ts +172 -0
- package/src/engine/notify-kit/platform/index.ts +55 -0
- package/src/engine/notify-kit/platform/ios.ts +54 -0
- package/src/engine/notify-kit/platform/types.ts +40 -0
- package/src/engine/notify-kit-engine.ts +96 -468
- package/src/index.ts +9 -0
- package/src/initialize.ts +10 -3
- package/src/notifications.ts +195 -0
- package/src/permission/index.ts +65 -4
- package/src/push/fcm-provider.ts +28 -11
- package/src/push/gate.ts +27 -5
- package/src/reconcile/diff.ts +38 -27
- package/src/reconcile/index.ts +13 -4
- package/src/recurrence/index.ts +2 -1
- package/src/repeat/expiry.ts +2 -6
- package/src/repeat/index.ts +45 -1
- package/src/repeat/marker.ts +1 -22
- package/src/repeat/place.ts +74 -35
- package/src/schedule/anchor.ts +35 -19
- package/src/schedule/index.ts +10 -4
- package/src/schedule/partition.ts +0 -19
- package/src/schedule/validate-identifier.ts +8 -6
- package/src/schedule/writer.ts +13 -4
- package/src/state/caps.ts +28 -3
- package/src/state/interaction-buffer.ts +30 -2
- package/src/state/mmkv-store.ts +54 -37
- package/src/state/permission-history.ts +25 -0
- package/src/state/record.ts +114 -20
- package/src/state/store.ts +3 -0
- 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';
|
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,
|
|
@@ -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
|
+
}
|
package/src/permission/index.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
46
|
-
exactAlarm:
|
|
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
|
-
|
|
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(
|
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 {
|
|
@@ -81,12 +88,22 @@ export function createFcmProvider(options: FcmProviderOptions = {}): PushProvide
|
|
|
81
88
|
// the optional peer. Either way the local path is unaffected.
|
|
82
89
|
if (instance === undefined) return 'unavailable';
|
|
83
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).
|
|
84
94
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
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';
|
|
87
105
|
} catch {
|
|
88
|
-
//
|
|
89
|
-
// `unknown` is reported rather than a guess at which one.
|
|
106
|
+
// The SDK does not say why, so `unknown` rather than a guess.
|
|
90
107
|
return 'unknown';
|
|
91
108
|
}
|
|
92
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
203
|
+
for (const unsubscribe of tokenSubscriptions) {
|
|
204
|
+
unsubscribe();
|
|
205
|
+
}
|
|
206
|
+
tokenSubscriptions.clear();
|
|
185
207
|
handlers = [];
|
|
186
208
|
},
|
|
187
209
|
};
|
package/src/reconcile/diff.ts
CHANGED
|
@@ -31,42 +31,53 @@ export interface DiffResult {
|
|
|
31
31
|
readonly unchanged: readonly string[];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
64
|
+
fields.routing === undefined ? null : stableStringify(fields.routing),
|
|
65
|
+
fields.actions === undefined
|
|
50
66
|
? null
|
|
51
|
-
:
|
|
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
|
|
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. */
|
package/src/reconcile/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
147
|
+
await pass.write({
|
|
139
148
|
request,
|
|
140
149
|
resolvedAt: operation.resolvedAt!,
|
|
141
150
|
tone,
|
package/src/recurrence/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/src/repeat/expiry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|