@chipmobilesdk/rn-notification 0.1.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 +42 -0
- package/README.md +306 -0
- package/package.json +73 -0
- package/src/channel/migrate.ts +211 -0
- package/src/channel/tone-map.ts +90 -0
- package/src/contract/channel.ts +89 -0
- package/src/contract/diagnostics.ts +102 -0
- package/src/contract/errors.ts +123 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/interaction.ts +62 -0
- package/src/contract/notification.ts +118 -0
- package/src/contract/permission.ts +60 -0
- package/src/contract/reconcile.ts +88 -0
- package/src/contract/recurrence.ts +123 -0
- package/src/diagnostics/burst.ts +101 -0
- package/src/diagnostics/events.ts +72 -0
- package/src/diagnostics/redact.ts +72 -0
- package/src/diagnostics/snapshot.ts +64 -0
- package/src/engine/adapter.ts +79 -0
- package/src/engine/notify-kit-engine.ts +535 -0
- package/src/index.ts +173 -0
- package/src/initialize.ts +174 -0
- package/src/interaction/index.ts +146 -0
- package/src/maintenance/cycle.ts +147 -0
- package/src/maintenance/permission-refresh.ts +41 -0
- package/src/maintenance/reanchor.ts +95 -0
- package/src/maintenance/toggle.ts +60 -0
- package/src/maintenance/verify.ts +83 -0
- package/src/maintenance/window-refresh.ts +61 -0
- package/src/permission/index.ts +183 -0
- package/src/permission/state.ts +77 -0
- package/src/push/envelope.ts +165 -0
- package/src/push/fcm-provider.ts +146 -0
- package/src/push/gate.ts +190 -0
- package/src/push/index.ts +29 -0
- package/src/push/instructions.ts +109 -0
- package/src/push/provider-adapter.ts +54 -0
- package/src/push/signout.ts +65 -0
- package/src/reconcile/chunk.ts +53 -0
- package/src/reconcile/diff.ts +145 -0
- package/src/reconcile/duplicates.ts +73 -0
- package/src/reconcile/index.ts +175 -0
- package/src/reconcile/truncate.ts +56 -0
- package/src/recurrence/clock-guard.ts +71 -0
- package/src/recurrence/expand.ts +292 -0
- package/src/recurrence/index.ts +177 -0
- package/src/schedule/anchor.ts +267 -0
- package/src/schedule/index.ts +173 -0
- package/src/schedule/partition.ts +85 -0
- package/src/schedule/resolve-moment.ts +80 -0
- package/src/schedule/validate-content.ts +111 -0
- package/src/schedule/validate-identifier.ts +114 -0
- package/src/state/caps.ts +71 -0
- package/src/state/dedup-ledger.ts +39 -0
- package/src/state/interaction-buffer.ts +73 -0
- package/src/state/mmkv-store.ts +71 -0
- package/src/state/permission-history.ts +41 -0
- package/src/state/record.ts +246 -0
- package/src/state/reset.ts +41 -0
- package/src/state/series-ledger.ts +84 -0
- package/src/state/store.ts +40 -0
- package/src/testing/conformance/index.ts +194 -0
- package/src/testing/fake-engine.ts +284 -0
- package/src/testing/fake-provider.ts +129 -0
- package/src/testing/faults.ts +72 -0
- package/src/testing/index.ts +26 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `react-native-notify-kit` engine implementation.
|
|
3
|
+
*
|
|
4
|
+
* Every import of the engine is confined to this file. Nothing above it knows
|
|
5
|
+
* which engine is in use — which is what allowed the original Notifee choice to
|
|
6
|
+
* be swapped for its maintained fork without touching a requirement, a test, or
|
|
7
|
+
* a line of consumer code.
|
|
8
|
+
*
|
|
9
|
+
* The module is required lazily so the package loads in Jest and in any
|
|
10
|
+
* environment without the native module. An absent engine is reported through
|
|
11
|
+
* the normal diagnostic path rather than crashing at import time.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Platform } from 'react-native';
|
|
15
|
+
import type {
|
|
16
|
+
NotificationEngineAdapter,
|
|
17
|
+
EngineCapabilities,
|
|
18
|
+
EngineScheduleRequest,
|
|
19
|
+
} from './adapter';
|
|
20
|
+
import type { PendingEntry } from '../contract/notification';
|
|
21
|
+
import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
|
|
22
|
+
import type { ResolvedTone, ChannelGroup } from '../contract/channel';
|
|
23
|
+
import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
|
|
24
|
+
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* iOS keeps only the 64 soonest pending notifications and silently discards the
|
|
28
|
+
* rest. This number is the entire reason truncation must be computed and
|
|
29
|
+
* reported by the package rather than left to the platform.
|
|
30
|
+
*/
|
|
31
|
+
const IOS_PENDING_CEILING = 64;
|
|
32
|
+
|
|
33
|
+
/** Android has no comparable hard ceiling; this bound keeps behavior predictable. */
|
|
34
|
+
const ANDROID_PENDING_BUDGET = 500;
|
|
35
|
+
|
|
36
|
+
/** Both platforms bound background execution for a silent payload or background action. */
|
|
37
|
+
const BACKGROUND_WINDOW_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The engine's own enum values, mirrored as literals.
|
|
41
|
+
*
|
|
42
|
+
* These are **numeric** enums in the engine's type definitions, and its native
|
|
43
|
+
* validator rejects the string names — `importance: 'default'` fails with
|
|
44
|
+
* "expected a valid Importance". Mirroring them here rather than importing the
|
|
45
|
+
* enums keeps the engine out of the module graph, which is what lets the
|
|
46
|
+
* package load without the native module present.
|
|
47
|
+
*
|
|
48
|
+
* The package's own `ResolvedTone` deliberately stays platform-neutral strings;
|
|
49
|
+
* translating to the engine's representation is this adapter's job and nobody
|
|
50
|
+
* else's.
|
|
51
|
+
*/
|
|
52
|
+
const ANDROID_IMPORTANCE = {
|
|
53
|
+
none: 0,
|
|
54
|
+
min: 1,
|
|
55
|
+
low: 2,
|
|
56
|
+
default: 3,
|
|
57
|
+
high: 4,
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
/** `TriggerType.TIMESTAMP`. */
|
|
61
|
+
const TRIGGER_TYPE_TIMESTAMP = 0;
|
|
62
|
+
|
|
63
|
+
/** `AlarmType.SET_EXACT_AND_ALLOW_WHILE_IDLE` — exact delivery, survives Doze. */
|
|
64
|
+
const ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE = 3;
|
|
65
|
+
|
|
66
|
+
/** `AlarmType.SET_AND_ALLOW_WHILE_IDLE` — the approximate degradation of the above. */
|
|
67
|
+
const ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE = 1;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `EventType` values that represent a real user interaction.
|
|
71
|
+
*
|
|
72
|
+
* The engine emits far more than interactions on this channel — `DELIVERED`,
|
|
73
|
+
* `TRIGGER_NOTIFICATION_CREATED`, `CHANNEL_BLOCKED` and others all arrive here.
|
|
74
|
+
* Treating anything that is not a dismissal as a press would report a
|
|
75
|
+
* notification *arriving* while the app is open as the user having tapped it,
|
|
76
|
+
* and the interaction path cancels on tap — so the notification would vanish
|
|
77
|
+
* from the tray in front of the user, unread.
|
|
78
|
+
*/
|
|
79
|
+
const EVENT_TYPE_DISMISSED = 0;
|
|
80
|
+
const EVENT_TYPE_PRESS = 1;
|
|
81
|
+
const EVENT_TYPE_ACTION_PRESS = 2;
|
|
82
|
+
|
|
83
|
+
/** Minimal shape used from the engine, so its full surface never leaks into typing. */
|
|
84
|
+
interface NotifyKitModule {
|
|
85
|
+
createChannel(channel: Record<string, unknown>): Promise<string>;
|
|
86
|
+
deleteChannel(channelId: string): Promise<void>;
|
|
87
|
+
getChannels(): Promise<{ id: string }[]>;
|
|
88
|
+
createChannelGroup(group: Record<string, unknown>): Promise<string>;
|
|
89
|
+
createTriggerNotification(
|
|
90
|
+
notification: Record<string, unknown>,
|
|
91
|
+
trigger: Record<string, unknown>,
|
|
92
|
+
): Promise<string>;
|
|
93
|
+
cancelNotification(id: string): Promise<void>;
|
|
94
|
+
cancelAllNotifications(): Promise<void>;
|
|
95
|
+
getTriggerNotifications(): Promise<Record<string, unknown>[]>;
|
|
96
|
+
requestPermission(options?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
97
|
+
getNotificationSettings(): Promise<Record<string, unknown>>;
|
|
98
|
+
openNotificationSettings(): Promise<void>;
|
|
99
|
+
openAlarmPermissionSettings(): Promise<void>;
|
|
100
|
+
isBatteryOptimizationEnabled(): Promise<boolean>;
|
|
101
|
+
onForegroundEvent(handler: (event: Record<string, unknown>) => void): () => void;
|
|
102
|
+
getInitialNotification(): Promise<Record<string, unknown> | null>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Drops keys whose value is `undefined`.
|
|
107
|
+
*
|
|
108
|
+
* The engine validates its arguments by key presence, not by value: passing
|
|
109
|
+
* `{ groupId: undefined }` fails with "expected a string value", while omitting
|
|
110
|
+
* the key entirely is accepted. JavaScript treats the two as interchangeable
|
|
111
|
+
* almost everywhere, which is exactly why this is easy to get wrong — and why
|
|
112
|
+
* it only surfaces on a real device, where the native validator runs.
|
|
113
|
+
*/
|
|
114
|
+
function compact<T extends Record<string, unknown>>(value: T): Partial<T> {
|
|
115
|
+
const output: Record<string, unknown> = {};
|
|
116
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
117
|
+
if (entry !== undefined) output[key] = entry;
|
|
118
|
+
}
|
|
119
|
+
return output as Partial<T>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function loadEngine(): NotifyKitModule {
|
|
123
|
+
try {
|
|
124
|
+
const module = require('react-native-notify-kit') as { default: NotifyKitModule };
|
|
125
|
+
return module.default;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new NotificationError(
|
|
128
|
+
DIAGNOSTIC_CODES.PUSH_UNAVAILABLE,
|
|
129
|
+
'react-native-notify-kit is not installed or its native module is not linked. Install it as a peer dependency and rebuild the app.',
|
|
130
|
+
{ reason: error instanceof Error ? error.name : 'unknown' },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Maps engine authorization values onto our six-state model.
|
|
137
|
+
*
|
|
138
|
+
* `notAsked` and `denied` must stay distinguishable — being able to read state
|
|
139
|
+
* without prompting is worthless if the two collapse. `provisional` is its own
|
|
140
|
+
* state on both edges, never folded into granted or denied.
|
|
141
|
+
*/
|
|
142
|
+
function toPermissionState(raw: unknown): PermissionState {
|
|
143
|
+
switch (raw) {
|
|
144
|
+
case 1:
|
|
145
|
+
case 'authorized':
|
|
146
|
+
return 'granted';
|
|
147
|
+
case 2:
|
|
148
|
+
case 'provisional':
|
|
149
|
+
return 'provisional';
|
|
150
|
+
case 0:
|
|
151
|
+
case 'denied':
|
|
152
|
+
return 'denied';
|
|
153
|
+
case -1:
|
|
154
|
+
case 'notDetermined':
|
|
155
|
+
return 'notAsked';
|
|
156
|
+
default:
|
|
157
|
+
return 'notAsked';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface NotifyKitEngineOptions {
|
|
162
|
+
/** Injected in tests; production resolves the real module. */
|
|
163
|
+
readonly module?: NotifyKitModule;
|
|
164
|
+
readonly now?: () => number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createNotifyKitEngine(
|
|
168
|
+
options: NotifyKitEngineOptions = {},
|
|
169
|
+
): NotificationEngineAdapter {
|
|
170
|
+
let cached: NotifyKitModule | undefined = options.module;
|
|
171
|
+
const engine = (): NotifyKitModule => (cached ??= loadEngine());
|
|
172
|
+
const now = options.now ?? (() => Date.now());
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Round-trips our own metadata through the engine's free-form data bag.
|
|
176
|
+
*
|
|
177
|
+
* Anchoring mode, generation, and series origin have no engine equivalent,
|
|
178
|
+
* and `getPending` must return them (FR-010) or a consumer cannot diff
|
|
179
|
+
* against intent. Values are stringified because the platform data bag
|
|
180
|
+
* carries strings on both platforms.
|
|
181
|
+
*/
|
|
182
|
+
const packData = (request: EngineScheduleRequest): Record<string, string> => ({
|
|
183
|
+
__cms_anchoring: request.request.moment.anchoring ?? 'wallClock',
|
|
184
|
+
// The IANA zone, not the tone. Storing the wrong value here does not merely
|
|
185
|
+
// mislabel an entry: the re-anchoring pass feeds it straight to
|
|
186
|
+
// Intl.DateTimeFormat, which throws a hard Hermes error on an invalid zone.
|
|
187
|
+
...(request.anchoringGeneration !== undefined
|
|
188
|
+
? { __cms_generation: request.anchoringGeneration }
|
|
189
|
+
: {}),
|
|
190
|
+
__cms_tone: request.request.tone,
|
|
191
|
+
__cms_approximate: String(request.approximate),
|
|
192
|
+
__cms_pushCreated: String(request.pushCreated ?? false),
|
|
193
|
+
...(request.seriesId !== undefined ? { __cms_series: request.seriesId } : {}),
|
|
194
|
+
...(request.request.ownerTag !== undefined ? { __cms_owner: request.request.ownerTag } : {}),
|
|
195
|
+
...(request.request.groupTag !== undefined ? { __cms_group: request.request.groupTag } : {}),
|
|
196
|
+
...(request.request.routing !== undefined
|
|
197
|
+
? { __cms_routing: JSON.stringify(request.request.routing) }
|
|
198
|
+
: {}),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const unpackEntry = (raw: Record<string, unknown>): PendingEntry | undefined => {
|
|
202
|
+
const notification = (raw.notification ?? {}) as Record<string, unknown>;
|
|
203
|
+
const trigger = (raw.trigger ?? {}) as Record<string, unknown>;
|
|
204
|
+
const data = (notification.data ?? {}) as Record<string, string>;
|
|
205
|
+
const id = notification.id;
|
|
206
|
+
|
|
207
|
+
if (typeof id !== 'string') return undefined;
|
|
208
|
+
|
|
209
|
+
let routing: Record<string, unknown> | undefined;
|
|
210
|
+
if (typeof data.__cms_routing === 'string') {
|
|
211
|
+
try {
|
|
212
|
+
routing = JSON.parse(data.__cms_routing) as Record<string, unknown>;
|
|
213
|
+
} catch {
|
|
214
|
+
// A payload we cannot parse is reported as absent rather than as a
|
|
215
|
+
// crash: the notification is still valid, only its routing is lost.
|
|
216
|
+
routing = undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
id,
|
|
222
|
+
resolvedAt: typeof trigger.timestamp === 'number' ? trigger.timestamp : now(),
|
|
223
|
+
anchoring: data.__cms_anchoring === 'absolute' ? 'absolute' : 'wallClock',
|
|
224
|
+
anchoringGeneration:
|
|
225
|
+
data.__cms_anchoring === 'absolute' ? undefined : data.__cms_generation,
|
|
226
|
+
content: {
|
|
227
|
+
title: typeof notification.title === 'string' ? notification.title : '',
|
|
228
|
+
body: typeof notification.body === 'string' ? notification.body : '',
|
|
229
|
+
subtitle: typeof notification.subtitle === 'string' ? notification.subtitle : undefined,
|
|
230
|
+
},
|
|
231
|
+
tone: data.__cms_tone ?? '',
|
|
232
|
+
routing,
|
|
233
|
+
ownerTag: data.__cms_owner,
|
|
234
|
+
groupTag: data.__cms_group,
|
|
235
|
+
seriesId: data.__cms_series,
|
|
236
|
+
mayBeDelayed: data.__cms_approximate === 'true',
|
|
237
|
+
pushCreated: data.__cms_pushCreated === 'true',
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const toInteraction = (raw: Record<string, unknown>): InteractionEvent | undefined => {
|
|
242
|
+
const eventType = raw.type;
|
|
243
|
+
|
|
244
|
+
// Only a dismissal or a press is an interaction. Everything else the
|
|
245
|
+
// engine emits on this channel — delivery, trigger creation, channel
|
|
246
|
+
// blocking — is not, and must not be reported as one.
|
|
247
|
+
if (
|
|
248
|
+
eventType !== EVENT_TYPE_DISMISSED &&
|
|
249
|
+
eventType !== EVENT_TYPE_PRESS &&
|
|
250
|
+
eventType !== EVENT_TYPE_ACTION_PRESS
|
|
251
|
+
) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const detail = (raw.detail ?? {}) as Record<string, unknown>;
|
|
256
|
+
const notification = (detail.notification ?? {}) as Record<string, unknown>;
|
|
257
|
+
const pressAction = (detail.pressAction ?? {}) as Record<string, unknown>;
|
|
258
|
+
const id = notification.id;
|
|
259
|
+
|
|
260
|
+
if (typeof id !== 'string') return undefined;
|
|
261
|
+
|
|
262
|
+
const data = (notification.data ?? {}) as Record<string, string>;
|
|
263
|
+
let routing: Record<string, unknown> | undefined;
|
|
264
|
+
if (typeof data.__cms_routing === 'string') {
|
|
265
|
+
try {
|
|
266
|
+
routing = JSON.parse(data.__cms_routing) as Record<string, unknown>;
|
|
267
|
+
} catch {
|
|
268
|
+
routing = undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const actionId = typeof pressAction.id === 'string' ? pressAction.id : undefined;
|
|
273
|
+
// `default` is the engine's own press-action id for tapping the body, so
|
|
274
|
+
// it is a tap rather than a named action.
|
|
275
|
+
const isNamedAction = actionId !== undefined && actionId !== 'default';
|
|
276
|
+
let kind: InteractionEvent['kind'];
|
|
277
|
+
if (eventType === EVENT_TYPE_DISMISSED) {
|
|
278
|
+
kind = 'dismiss';
|
|
279
|
+
} else if (isNamedAction) {
|
|
280
|
+
kind = 'action';
|
|
281
|
+
} else {
|
|
282
|
+
kind = 'tap';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
kind,
|
|
287
|
+
notificationId: id,
|
|
288
|
+
actionId: actionId !== 'default' ? actionId : undefined,
|
|
289
|
+
inputText: typeof detail.input === 'string' ? detail.input : undefined,
|
|
290
|
+
routing,
|
|
291
|
+
ownerTag: data.__cms_owner,
|
|
292
|
+
occurredAt: now(),
|
|
293
|
+
appState: 'foreground',
|
|
294
|
+
wasBuffered: false,
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
name: 'react-native-notify-kit',
|
|
300
|
+
|
|
301
|
+
async getCapabilities(): Promise<EngineCapabilities> {
|
|
302
|
+
return {
|
|
303
|
+
pendingCeiling: Platform.OS === 'ios' ? IOS_PENDING_CEILING : ANDROID_PENDING_BUDGET,
|
|
304
|
+
supportsExactAlarms: Platform.OS === 'android',
|
|
305
|
+
supportsChannels: Platform.OS === 'android',
|
|
306
|
+
supportsTextInputActions: true,
|
|
307
|
+
supportsDoNotDisturbBypass: Platform.OS === 'android',
|
|
308
|
+
backgroundWindowMs: BACKGROUND_WINDOW_MS,
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async schedule(request: EngineScheduleRequest): Promise<void> {
|
|
313
|
+
const actions = request.request.actions?.map(action =>
|
|
314
|
+
compact({
|
|
315
|
+
title: action.title,
|
|
316
|
+
pressAction: compact({
|
|
317
|
+
id: action.id,
|
|
318
|
+
launchActivity: action.mode === 'foreground' ? 'default' : undefined,
|
|
319
|
+
}),
|
|
320
|
+
input: action.input === undefined ? undefined : compact({ placeholder: action.input.placeholder }),
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
await engine().createTriggerNotification(
|
|
325
|
+
compact({
|
|
326
|
+
id: request.request.id,
|
|
327
|
+
title: request.request.content.title,
|
|
328
|
+
body: request.request.content.body,
|
|
329
|
+
subtitle: request.request.content.subtitle,
|
|
330
|
+
data: packData(request),
|
|
331
|
+
android: compact({
|
|
332
|
+
channelId: request.tone.android.channelId,
|
|
333
|
+
// Numeric enum, not the string name — see ANDROID_IMPORTANCE.
|
|
334
|
+
importance: ANDROID_IMPORTANCE[request.tone.android.importance],
|
|
335
|
+
sound: request.tone.android.sound,
|
|
336
|
+
groupId: request.tone.android.groupId,
|
|
337
|
+
actions,
|
|
338
|
+
}),
|
|
339
|
+
ios: compact({
|
|
340
|
+
categoryId: request.tone.ios.categoryId,
|
|
341
|
+
// This one genuinely is a string union in the engine's types.
|
|
342
|
+
interruptionLevel: request.tone.ios.interruptionLevel,
|
|
343
|
+
sound: request.tone.ios.sound,
|
|
344
|
+
}),
|
|
345
|
+
}),
|
|
346
|
+
{
|
|
347
|
+
type: TRIGGER_TYPE_TIMESTAMP,
|
|
348
|
+
timestamp: request.resolvedAt,
|
|
349
|
+
// Approximate mode is a degradation, not a failure: the notification
|
|
350
|
+
// is still scheduled and the caller is told it may arrive late.
|
|
351
|
+
alarmManager: {
|
|
352
|
+
allowWhileIdle: true,
|
|
353
|
+
type: request.approximate
|
|
354
|
+
? ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE
|
|
355
|
+
: ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE,
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
async cancel(id: string): Promise<void> {
|
|
362
|
+
await engine().cancelNotification(id);
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async cancelMany(ids: readonly string[]): Promise<void> {
|
|
366
|
+
// Sequential rather than parallel: the engine bridges each call to
|
|
367
|
+
// native, and a burst of concurrent cancels is where duplicate-delivery
|
|
368
|
+
// races have historically appeared.
|
|
369
|
+
for (const id of ids) {
|
|
370
|
+
await engine().cancelNotification(id);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
async cancelAll(): Promise<void> {
|
|
375
|
+
await engine().cancelAllNotifications();
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
async getPending(): Promise<readonly PendingEntry[]> {
|
|
379
|
+
const raw = await engine().getTriggerNotifications();
|
|
380
|
+
return raw
|
|
381
|
+
.map(unpackEntry)
|
|
382
|
+
.filter((entry): entry is PendingEntry => entry !== undefined)
|
|
383
|
+
.sort((a, b) => a.resolvedAt - b.resolvedAt);
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async declareTone(tone: ResolvedTone): Promise<boolean> {
|
|
387
|
+
if (Platform.OS !== 'android') {
|
|
388
|
+
// iOS categories are declared at request time rather than up front.
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const existing = await engine().getChannels();
|
|
393
|
+
if (existing.some(channel => channel.id === tone.android.channelId)) {
|
|
394
|
+
// Android silently ignores re-declaration, which is exactly why the
|
|
395
|
+
// package keeps its own migration ledger rather than asking here.
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await engine().createChannel(
|
|
400
|
+
compact({
|
|
401
|
+
id: tone.android.channelId,
|
|
402
|
+
name: tone.toneId,
|
|
403
|
+
importance: ANDROID_IMPORTANCE[tone.android.importance],
|
|
404
|
+
sound: tone.android.sound,
|
|
405
|
+
vibration: tone.android.vibration,
|
|
406
|
+
badge: tone.android.badge,
|
|
407
|
+
bypassDnd: tone.android.bypassDnd,
|
|
408
|
+
groupId: tone.android.groupId,
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
return true;
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async retireTone(channelId: string): Promise<boolean> {
|
|
415
|
+
if (Platform.OS !== 'android') return false;
|
|
416
|
+
|
|
417
|
+
const existing = await engine().getChannels();
|
|
418
|
+
if (!existing.some(channel => channel.id === channelId)) return false;
|
|
419
|
+
|
|
420
|
+
await engine().deleteChannel(channelId);
|
|
421
|
+
return true;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
async declareGroups(groups: readonly ChannelGroup[]): Promise<void> {
|
|
425
|
+
if (Platform.OS !== 'android') return;
|
|
426
|
+
|
|
427
|
+
for (const group of groups) {
|
|
428
|
+
await engine().createChannelGroup(
|
|
429
|
+
compact({ id: group.id, name: group.name, description: group.description }),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
async listDeclaredChannels(): Promise<readonly string[]> {
|
|
435
|
+
if (Platform.OS !== 'android') return [];
|
|
436
|
+
const channels = await engine().getChannels();
|
|
437
|
+
return channels.map(channel => channel.id);
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
async getPermission(kind: PermissionKind): Promise<PermissionState> {
|
|
441
|
+
const settings = await engine().getNotificationSettings();
|
|
442
|
+
|
|
443
|
+
if (kind === 'display') {
|
|
444
|
+
// Android below 13 has no runtime display permission at all.
|
|
445
|
+
if (Platform.OS === 'android' && (Platform.Version as number) < 33) {
|
|
446
|
+
return 'notRequired';
|
|
447
|
+
}
|
|
448
|
+
return toPermissionState(settings.authorizationStatus);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Exact alarms exist only on Android 12+; elsewhere the concept does not
|
|
452
|
+
// apply, which is "not required" rather than "denied".
|
|
453
|
+
if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
|
|
454
|
+
return 'notRequired';
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const android = (settings.android ?? {}) as Record<string, unknown>;
|
|
458
|
+
return android.alarm === 1 ? 'granted' : 'denied';
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
async requestPermission(kind: PermissionKind): Promise<PermissionState> {
|
|
462
|
+
if (kind === 'display') {
|
|
463
|
+
const result = await engine().requestPermission();
|
|
464
|
+
return toPermissionState(result.authorizationStatus);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Exact-alarm capability is granted only through system settings on the
|
|
468
|
+
// versions that gate it — there is no in-app dialog to present.
|
|
469
|
+
if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
|
|
470
|
+
return 'notRequired';
|
|
471
|
+
}
|
|
472
|
+
await engine().openAlarmPermissionSettings();
|
|
473
|
+
return this.getPermission('exactAlarm');
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
async openSettings(kind: PermissionKind): Promise<void> {
|
|
477
|
+
if (kind === 'exactAlarm') {
|
|
478
|
+
if (Platform.OS !== 'android' || (Platform.Version as number) < 31) {
|
|
479
|
+
throw new NotificationError(
|
|
480
|
+
DIAGNOSTIC_CODES.SETTINGS_UNAVAILABLE,
|
|
481
|
+
'No exact-alarm settings screen exists on this platform version.',
|
|
482
|
+
{ platform: Platform.OS, version: Platform.Version },
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
await engine().openAlarmPermissionSettings();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
await engine().openNotificationSettings();
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
async getRestrictions(): Promise<RestrictionReport> {
|
|
492
|
+
if (Platform.OS !== 'android') {
|
|
493
|
+
return {
|
|
494
|
+
batteryOptimization: 'unrestricted',
|
|
495
|
+
autoStart: 'unrestricted',
|
|
496
|
+
readAt: now(),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
let batteryOptimization: RestrictionReport['batteryOptimization'] = 'unknown';
|
|
501
|
+
try {
|
|
502
|
+
batteryOptimization = (await engine().isBatteryOptimizationEnabled())
|
|
503
|
+
? 'restricted'
|
|
504
|
+
: 'unrestricted';
|
|
505
|
+
} catch {
|
|
506
|
+
batteryOptimization = 'unknown';
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
batteryOptimization,
|
|
511
|
+
// No standard API exposes vendor auto-start policy, and guessing would
|
|
512
|
+
// be worse than admitting it. `unknown` is a first-class answer here.
|
|
513
|
+
autoStart: 'unknown',
|
|
514
|
+
readAt: now(),
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
subscribeToInteractions(handler: (event: InteractionEvent) => void): Unsubscribe {
|
|
519
|
+
return engine().onForegroundEvent(raw => {
|
|
520
|
+
const event = toInteraction(raw);
|
|
521
|
+
if (event !== undefined) handler(event);
|
|
522
|
+
});
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
async takeInitialInteraction(): Promise<InteractionEvent | undefined> {
|
|
526
|
+
const raw = await engine().getInitialNotification();
|
|
527
|
+
if (raw === null) return undefined;
|
|
528
|
+
|
|
529
|
+
const event = toInteraction(raw);
|
|
530
|
+
// A cold-start tap arrives before any handler exists, so it is reported
|
|
531
|
+
// with the state that actually produced it.
|
|
532
|
+
return event === undefined ? undefined : { ...event, appState: 'coldStart' };
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chipmobilesdk/rn-notification
|
|
3
|
+
*
|
|
4
|
+
* Default entrypoint: scheduling, recurrence, reconciliation, permissions,
|
|
5
|
+
* channels, interaction, and diagnostics.
|
|
6
|
+
*
|
|
7
|
+
* Push lives behind `@chipmobilesdk/rn-notification/push` so an app that never
|
|
8
|
+
* enables it ships no provider code, no push permissions, and no entitlements.
|
|
9
|
+
* Test doubles live behind `@chipmobilesdk/rn-notification/testing` so they
|
|
10
|
+
* never reach a production bundle.
|
|
11
|
+
*
|
|
12
|
+
* No engine, provider, or storage type is reachable from here — that is what
|
|
13
|
+
* makes each of them replaceable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type {
|
|
17
|
+
// Notification
|
|
18
|
+
AnchoringMode,
|
|
19
|
+
DeliveryMoment,
|
|
20
|
+
NotificationContent,
|
|
21
|
+
RoutingPayload,
|
|
22
|
+
Action,
|
|
23
|
+
NotificationRequest,
|
|
24
|
+
PendingEntry,
|
|
25
|
+
ScheduleResult,
|
|
26
|
+
// Recurrence
|
|
27
|
+
Weekday,
|
|
28
|
+
DailyPattern,
|
|
29
|
+
WeeklyPattern,
|
|
30
|
+
MonthlyByDayPattern,
|
|
31
|
+
RecurrencePattern,
|
|
32
|
+
EndCondition,
|
|
33
|
+
OccurrenceTemplate,
|
|
34
|
+
RecurrenceRule,
|
|
35
|
+
LookaheadConfig,
|
|
36
|
+
SeriesResult,
|
|
37
|
+
SeriesSummary,
|
|
38
|
+
// Reconciliation
|
|
39
|
+
ReconcileInput,
|
|
40
|
+
ReconcileResult,
|
|
41
|
+
RejectedEntry,
|
|
42
|
+
TruncationReport,
|
|
43
|
+
PendingDiscrepancy,
|
|
44
|
+
MaintenanceResult,
|
|
45
|
+
// Permission
|
|
46
|
+
PermissionKind,
|
|
47
|
+
PermissionState,
|
|
48
|
+
PermissionReport,
|
|
49
|
+
PermissionCapability,
|
|
50
|
+
RestrictionStatus,
|
|
51
|
+
RestrictionReport,
|
|
52
|
+
// Channel
|
|
53
|
+
ToneUrgency,
|
|
54
|
+
ToneSound,
|
|
55
|
+
ToneDeclaration,
|
|
56
|
+
ChannelGroup,
|
|
57
|
+
ChannelApplyResult,
|
|
58
|
+
// Reachable through EngineScheduleRequest, so the engine port is unusable
|
|
59
|
+
// without it — a consumer supplying its own engine must be able to name it.
|
|
60
|
+
ResolvedTone,
|
|
61
|
+
// Interaction
|
|
62
|
+
InteractionKind,
|
|
63
|
+
AppStateAtInteraction,
|
|
64
|
+
InteractionEvent,
|
|
65
|
+
InteractionHandler,
|
|
66
|
+
TargetMissingSignal,
|
|
67
|
+
Unsubscribe,
|
|
68
|
+
// Diagnostics
|
|
69
|
+
DiagnosticSeverity,
|
|
70
|
+
DiagnosticEvent,
|
|
71
|
+
EventSink,
|
|
72
|
+
CollectionHealth,
|
|
73
|
+
StateHealth,
|
|
74
|
+
StateSnapshot,
|
|
75
|
+
BurstThreshold,
|
|
76
|
+
NotificationConfig,
|
|
77
|
+
InitializeResult,
|
|
78
|
+
} from './contract';
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
DEFAULT_ANCHORING_MODE,
|
|
82
|
+
DEFAULT_LOOKAHEAD,
|
|
83
|
+
LOOKAHEAD_BOUNDS,
|
|
84
|
+
INTERACTION_BUFFER_LIMITS,
|
|
85
|
+
MAX_ROUTING_PAYLOAD_BYTES,
|
|
86
|
+
MAX_ACTIONS_PER_NOTIFICATION,
|
|
87
|
+
DEFAULT_BURST_THRESHOLD,
|
|
88
|
+
DIAGNOSTIC_CODES,
|
|
89
|
+
ALL_DIAGNOSTIC_CODES,
|
|
90
|
+
NotificationError,
|
|
91
|
+
isNotificationError,
|
|
92
|
+
isNonFailure,
|
|
93
|
+
} from './contract';
|
|
94
|
+
|
|
95
|
+
export type { DiagnosticCode } from './contract';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The engine port. Exported so a consuming app can supply its own engine, which
|
|
99
|
+
* is the mechanism behind provider and engine substitution.
|
|
100
|
+
*/
|
|
101
|
+
export type {
|
|
102
|
+
NotificationEngineAdapter,
|
|
103
|
+
EngineCapabilities,
|
|
104
|
+
EngineScheduleRequest,
|
|
105
|
+
} from './engine/adapter';
|
|
106
|
+
|
|
107
|
+
/** The default engine. Consumers may substitute their own via the port above. */
|
|
108
|
+
export { createNotifyKitEngine } from './engine/notify-kit-engine';
|
|
109
|
+
|
|
110
|
+
/** Initialization and the assembled runtime. */
|
|
111
|
+
export { initialize } from './initialize';
|
|
112
|
+
export type { RuntimeDependencies, NotificationRuntime } from './initialize';
|
|
113
|
+
|
|
114
|
+
/** Scheduling operations. */
|
|
115
|
+
export { createScheduler } from './schedule';
|
|
116
|
+
export type { Scheduler, ScheduleDependencies } from './schedule';
|
|
117
|
+
|
|
118
|
+
/** Reconciliation — always consumer-invoked, never automatic. */
|
|
119
|
+
export { createReconciler } from './reconcile';
|
|
120
|
+
export type { Reconciler, ReconcileDependencies } from './reconcile';
|
|
121
|
+
|
|
122
|
+
/** Recurrence — declare a rule once; the package maintains a bounded window. */
|
|
123
|
+
export { createRecurrence } from './recurrence';
|
|
124
|
+
export type { Recurrence, RecurrenceDependencies } from './recurrence';
|
|
125
|
+
export { occurrenceId } from './recurrence/expand';
|
|
126
|
+
|
|
127
|
+
/** Channels and tones — declared once, mapped to each platform, migrated on change. */
|
|
128
|
+
export { createChannels } from './channel/migrate';
|
|
129
|
+
export type { Channels, ChannelDependencies } from './channel/migrate';
|
|
130
|
+
export { channelIdFor, createToneResolver, parseChannelId, resolveTone } from './channel/tone-map';
|
|
131
|
+
|
|
132
|
+
/** Interaction routing — one handler for foreground, background, and cold start. */
|
|
133
|
+
export { createInteractions } from './interaction';
|
|
134
|
+
export type { Interactions, InteractionDependencies } from './interaction';
|
|
135
|
+
|
|
136
|
+
/** Observability — snapshot and burst detection. */
|
|
137
|
+
export { createSnapshot } from './diagnostics/snapshot';
|
|
138
|
+
export type { Snapshot, SnapshotDependencies } from './diagnostics/snapshot';
|
|
139
|
+
export { createBurstDetector } from './diagnostics/burst';
|
|
140
|
+
export type { BurstDetector, BurstStatus } from './diagnostics/burst';
|
|
141
|
+
export { DiagnosticEmitter } from './diagnostics/events';
|
|
142
|
+
|
|
143
|
+
/** Permission coordination — reading never prompts; a refusal never fails the caller. */
|
|
144
|
+
export { createPermissions } from './permission';
|
|
145
|
+
export type { Permissions, PermissionDependencies, PermissionChangeHandler } from './permission';
|
|
146
|
+
export { canDisplay, capabilityFor, isTerminal, shouldOfferSettings } from './permission/state';
|
|
147
|
+
export type { PlatformInfo } from './permission/state';
|
|
148
|
+
|
|
149
|
+
/** Maintenance steps, registered with the runtime's registry. */
|
|
150
|
+
export { createPermissionRefreshStep } from './maintenance/permission-refresh';
|
|
151
|
+
export { createReAnchorStep } from './maintenance/reanchor';
|
|
152
|
+
export { createVerifyStep } from './maintenance/verify';
|
|
153
|
+
export { createWindowRefreshStep } from './maintenance/window-refresh';
|
|
154
|
+
export type { MaintenanceStep, MaintenanceTrigger, MaintenanceContext } from './maintenance/cycle';
|
|
155
|
+
export { MaintenanceRegistry, MaintenanceScopeViolation } from './maintenance/cycle';
|
|
156
|
+
export { MaintenanceController } from './maintenance/toggle';
|
|
157
|
+
|
|
158
|
+
/** Public state controls — the only two ways a consumer may touch persisted state. */
|
|
159
|
+
export { resetState, clearOwner } from './state/reset';
|
|
160
|
+
export type { ResetOutcome } from './state/reset';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* State plumbing. The record's *schema* stays private — a consumer can create
|
|
164
|
+
* and reset the store, but has no way to read or write what is inside it.
|
|
165
|
+
*/
|
|
166
|
+
export { createMmkvStore } from './state/mmkv-store';
|
|
167
|
+
export { StateManager } from './state/record';
|
|
168
|
+
export type { StateStore } from './state/store';
|
|
169
|
+
|
|
170
|
+
/** Documented limits, exported so a consumer can validate before calling. */
|
|
171
|
+
export { MAX_IDENTIFIER_LENGTH } from './schedule/validate-identifier';
|
|
172
|
+
export { CONTENT_LIMITS } from './schedule/validate-content';
|
|
173
|
+
export { PAST_TOLERANCE_MS } from './schedule/resolve-moment';
|