@chipmobilesdk/rn-notification 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +174 -166
  2. package/README.md +414 -410
  3. package/package.json +58 -75
  4. package/skills/sdk-rn-notification/SKILL.md +172 -173
  5. package/src/channel/migrate.ts +285 -289
  6. package/src/channel/tone-map.ts +130 -130
  7. package/src/channel/validate-tone.ts +61 -61
  8. package/src/contract/channel.ts +192 -188
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +134 -134
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +55 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +129 -129
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +67 -64
  21. package/src/engine/adapter.ts +172 -172
  22. package/src/engine/codec.ts +89 -89
  23. package/src/engine/notify-kit-engine.ts +706 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +240 -236
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -154
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -101
  30. package/src/maintenance/toggle.ts +66 -64
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -64
  33. package/src/permission/index.ts +183 -183
  34. package/src/permission/state.ts +77 -77
  35. package/src/push/envelope.ts +165 -165
  36. package/src/push/fcm-provider.ts +143 -146
  37. package/src/push/gate.ts +190 -190
  38. package/src/push/index.ts +33 -29
  39. package/src/push/instructions.ts +114 -109
  40. package/src/push/provider-adapter.ts +54 -54
  41. package/src/push/signout.ts +65 -65
  42. package/src/reconcile/chunk.ts +53 -53
  43. package/src/reconcile/diff.ts +142 -145
  44. package/src/reconcile/duplicates.ts +73 -73
  45. package/src/reconcile/index.ts +180 -180
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -319
  49. package/src/recurrence/index.ts +270 -259
  50. package/src/repeat/expiry.ts +53 -55
  51. package/src/repeat/index.ts +54 -54
  52. package/src/repeat/marker.ts +72 -72
  53. package/src/repeat/place.ts +73 -73
  54. package/src/schedule/anchor.ts +278 -267
  55. package/src/schedule/index.ts +174 -181
  56. package/src/schedule/partition.ts +88 -85
  57. package/src/schedule/resolve-moment.ts +80 -80
  58. package/src/schedule/validate-content.ts +111 -111
  59. package/src/schedule/validate-identifier.ts +126 -126
  60. package/src/schedule/writer.ts +55 -55
  61. package/src/state/caps.ts +71 -71
  62. package/src/state/dedup-ledger.ts +35 -35
  63. package/src/state/interaction-buffer.ts +73 -73
  64. package/src/state/mmkv-store.ts +75 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +266 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +40 -40
  70. package/src/testing/conformance/index.ts +258 -248
  71. package/src/testing/fake-engine.ts +462 -468
  72. package/src/testing/fake-provider.ts +126 -129
  73. package/src/testing/faults.ts +72 -72
  74. package/src/testing/index.ts +23 -28
  75. package/src/testing/measure.ts +337 -337
package/README.md CHANGED
@@ -1,410 +1,414 @@
1
- # @chipmobilesdk/rn-notification
2
-
3
- Shared notification library for bare React Native apps on iOS and Android.
4
-
5
- Scheduled local notifications with deterministic identity and idempotent reconciliation, provider-agnostic push, permission coordination, declarative channels and tones, action and tap routing across every app state, and a decision core you can test on a laptop.
6
-
7
- ## Why this exists
8
-
9
- Every notification feature re-solves the same problems, and most of them are discovered after shipping:
10
-
11
- - Identifiers must be **derived from data**, not random, or you can never replace a notification you already scheduled.
12
- - The OS will not hold an unbounded repeating series, and iOS silently discards past **64 pending notifications**.
13
- - Reconciling "what my data implies" against "what the device holds" must be **idempotent**, or it is untestable.
14
- - Display permission and exact-alarm permission are **different things**, asked separately, and a refusal must not break the user's actual work.
15
- - Android channels are **immutable once created** — changing one is a migration, not an assignment.
16
-
17
- ## Installation
18
-
19
- ```sh
20
- npm install @chipmobilesdk/rn-notification
21
- npm install react-native-notify-kit react-native-mmkv react-native-localize
22
- cd ios && pod install
23
- ```
24
-
25
- ### Peer dependencies
26
-
27
- | Package | Required | Purpose |
28
- |---|---|---|
29
- | `react` >= 19, `react-native` >= 0.85 | yes | Host framework |
30
- | `react-native-notify-kit` >= 10.5.0 | yes | Local notification engine |
31
- | `react-native-mmkv` >= 4.0.0 | yes | Bounded persisted state |
32
- | `react-native-localize` >= 3.7.0 | yes | Platform time zone for re-anchoring |
33
- | `@react-native-firebase/app` >= 26.0.0 | **optional** | Push only — required *by* messaging |
34
- | `@react-native-firebase/messaging` >= 26.0.0 | **optional** | Push only |
35
-
36
- ### Enabling push (optional)
37
-
38
- > ⚠️ **Install both Firebase packages, and add the config files.** `@react-native-firebase/messaging` will not build without `@react-native-firebase/app`, and `app` will not build without a Firebase config file. Installing messaging alone fails the Android build at configure time with `Could not find the react-native-firebase/app package`.
39
-
40
- ```sh
41
- npm install @react-native-firebase/app @react-native-firebase/messaging
42
- cd ios && pod install
43
- ```
44
-
45
- Then add the config files **before building** — they are not optional:
46
-
47
- - Android → `android/app/google-services.json`, plus the `com.google.gms:google-services` plugin
48
- - iOS → `ios/<App>/GoogleService-Info.plist`
49
-
50
- **If you are not enabling push, install none of this.** The package's push code lives behind the `/push` subpath and is never imported by the default entrypoint, so a local-only app ships zero provider code, zero push permissions, and zero entitlements — and takes on none of the provider's data-collection declarations.
51
-
52
- > **On the engine.** The original design named Notifee. Its repository was archived on 2026-04-07 with its last release in December 2024, so this package uses `react-native-notify-kit` — the maintained, 100%-API-compatible fork Invertase itself points to. The engine sits behind `NotificationEngineAdapter`; nothing consumer-facing depends on which one is in use.
53
-
54
- ## Native setup
55
-
56
- ### iOS
57
-
58
- Notification authorization needs **no `Info.plist` usage string**. Push additionally requires:
59
-
60
- - the `aps-environment` entitlement
61
- - the `remote-notification` background mode
62
-
63
- ### Android
64
-
65
- | Permission | When | Notes |
66
- |---|---|---|
67
- | `POST_NOTIFICATIONS` | API 33+ | Runtime; requested only when you call for it |
68
- | `RECEIVE_BOOT_COMPLETED` | always | Restores scheduled notifications after a restart |
69
- | `SCHEDULE_EXACT_ALARM` | **opt-in** | You add it; requires a Play Console declaration |
70
-
71
- **Never use `USE_EXACT_ALARM` unless your app *is* an alarm clock, timer, or calendar.** Google Play restricts it and will disallow publishing for apps that do not qualify.
72
-
73
- #### ⚠️ You must actively *remove* `SCHEDULE_EXACT_ALARM` if you do not need it
74
-
75
- This package declares no exact-alarm permission — but **the notification engine does**, unconditionally, in its own manifest. Android's manifest merger is transitive, so it lands in your merged manifest whether you asked for it or not. Verified on a real build: `SCHEDULE_EXACT_ALARM` appears in the installed package even when nothing opts in.
76
-
77
- `SCHEDULE_EXACT_ALARM` requires a **Play Console Restricted Permissions declaration**. If your app does not need precise timing, strip it:
78
-
79
- ```xml
80
- <!-- android/app/src/main/AndroidManifest.xml -->
81
- <manifest xmlns:android="http://schemas.android.com/apk/res/android"
82
- xmlns:tools="http://schemas.android.com/tools">
83
-
84
- <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
85
- tools:node="remove" />
86
- ```
87
-
88
- Without exact alarms, notifications are still scheduled — in approximate mode, with `mayBeDelayed: true` on the result. Nothing breaks.
89
-
90
- **Verify after building**, because a merged manifest is the only thing that tells the truth:
91
-
92
- ```sh
93
- adb shell dumpsys package <your.package.id> | grep -E "android\.permission\.(SCHEDULE|USE)_EXACT_ALARM"
94
- # no output = correctly removed
95
- ```
96
-
97
- > Match on `android.permission.` specifically. A loose `grep -i EXACT_ALARM` also hits
98
- > `android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` — a broadcast the
99
- > engine legitimately listens for, not a permission — and reports a false failure.
100
-
101
- The engine also contributes `WAKE_LOCK`, `VIBRATE`, `FOREGROUND_SERVICE`, `INTERNET`, and `ACCESS_NETWORK_STATE`. None is store-restricted, but review them against your own privacy declarations — and remove any you can prove unnecessary using the same `tools:node="remove"` mechanism.
102
-
103
- ### Backup exclusion
104
-
105
- State lives under `Library/Caches` on iOS (backup-excluded by platform design). On Android, add to your `data_extraction_rules.xml` and `full_backup_content`:
106
-
107
- ```xml
108
- <exclude domain="file" path="mmkv/chipmobilesdk.notification" />
109
- ```
110
-
111
- ## Choosing identifiers
112
-
113
- Identifiers are **yours**. The package never generates one, because only you can derive a reproducible identifier from your own data.
114
-
115
- ```ts
116
- const id = `task:${task.id}:reminder:${index}`; // ✅ derived, reproducible
117
- const id = crypto.randomUUID(); // ❌ you can never replace it
118
- ```
119
-
120
- **Charset**: `A–Z a–z 0–9` and `. _ - : /`, at most 128 characters. Whitespace and control characters are rejected.
121
-
122
- **Nothing is normalized.** `Task:1` and `task:1` are different identifiers. That is deliberate — silently folding them would hide a bug — but it is also how you accidentally create two notifications for one subject. Derive identifiers from a single canonical source.
123
-
124
- Owner tags and group tags follow the same rule.
125
-
126
- ## Choosing an anchoring mode
127
-
128
- ```ts
129
- moment: { at } // wall-clock (default)
130
- moment: { at, anchoring: 'absolute' } // fixed instant worldwide
131
- ```
132
-
133
- **Wall-clock** means the local clock time you named. An 08:00 reminder stays 08:00 after your user flies to Tokyo. This is the default because reminders are wall-clock things.
134
-
135
- **Absolute** means one instant everywhere — a match kickoff, a market open.
136
-
137
- ### What re-anchoring cannot fix
138
-
139
- The package re-anchors wall-clock notifications when it notices the device zone changed. It notices during maintenance, which runs while the app is running.
140
-
141
- **If a wall-clock notification's moment arrives while the app has not run since the zone changed, it fires at the previously anchored instant.** This is stated rather than hidden: every pending entry carries an `anchoringGeneration`, so you can detect and explain the condition instead of being told nothing happened.
142
-
143
- ## Maintenance versus reconciliation
144
-
145
- | The package does automatically | You call explicitly |
146
- |---|---|
147
- | Applies `config.tones` (and any channel migration) during `initialize()` | `reconcile()` — the desired set |
148
- | Runs every step on `runtime.registry` when the app returns to the foreground — with the trigger `timeZoneChange` when the device zone moved meanwhile | |
149
-
150
- The steps themselves need inputs only your app has — the series rules it registered, the identifiers it expects, its permission module — so you register them once on `runtime.registry` after `initialize()`:
151
-
152
- ```ts
153
- const { runtime } = await initialize(config, { engine, store });
154
- runtime.registry.register(createReAnchorStep({ engine, timeZone, resolveTone, canScheduleExactly }));
155
- runtime.registry.register(createWindowRefreshStep({ recurrence, registeredRules: () => myRules }));
156
- runtime.registry.register(createVerifyStep({ engine, expectedIds: () => myExpectedIds }));
157
- runtime.registry.register(createPermissionRefreshStep({ permissions }));
158
- // On teardown (tests, a sign-out that rebuilds the runtime):
159
- runtime.dispose();
160
- ```
161
-
162
- Before 0.4.0 nothing triggered these steps; they ran only when the app called `runMaintenance()` itself. A cycle that throws is reported as `MAINTENANCE_FAILED`, because nothing awaits it.
163
-
164
- **Reconciliation never runs on its own.** Only you know what your data implies; a package that guessed would cancel notifications it does not understand.
165
-
166
- **Automatic maintenance is strictly additive.** It may act only on recurrence series the package owns, and can *never* cancel a notification you scheduled directly. That rule is enforced in code (`MaintenanceScopeViolation`), not merely documented.
167
-
168
- Call `reconcile()` after: mutating data that implies notifications, sign-in, sign-out, display permission becoming granted, **a device restart**, and app start.
169
-
170
- ## Reconciliation
171
-
172
- ```ts
173
- const result = await reconciler.reconcile({ domain: 'tasks', desired });
174
- // result.operationCount === 0 on an unchanged second pass
175
- ```
176
-
177
- - **Idempotent** — a second consecutive pass with unchanged input performs **zero** platform operations.
178
- - **Domain-scoped** — never cancels outside `domain`, including notifications from other libraries.
179
- - **Duplicates**: every copy of a repeated identifier is rejected; anything already scheduled under it is **left in place**. A rejection is not a cancellation.
180
- - **Truncation**: over the platform ceiling, the furthest-out entries are dropped and *reported*. They return as nearer ones deliver.
181
-
182
- ## Recurrence
183
-
184
- ```ts
185
- await recurrence.registerSeries({
186
- id: 'series:medication',
187
- pattern: { kind: 'daily', hour: 8, minute: 0 },
188
- end: { kind: 'unbounded' },
189
- template: { content: { title: 'Medication', body: 'Time for your dose' }, tone: 'reminder' },
190
- });
191
- ```
192
-
193
- Default window: **60 days or 32 occurrences per series**, whichever comes first. Bounded by both because either alone fails — a duration alone lets a frequent series exhaust the platform ceiling; a count alone leaves an annual series scheduled a decade out.
194
-
195
- Months lacking the requested day are **skipped, not clamped**: day 31 means day 31.
196
-
197
- ## Tones and channels
198
-
199
- A tone is declared once and mapped to each platform, so your code contains no platform branches.
200
-
201
- ```ts
202
- await channels.apply([
203
- {
204
- id: 'medication',
205
- name: t('notifications.medication.name'), // shown in system settings
206
- description: t('notifications.medication.hint'),
207
- urgency: 'max',
208
- sound: { name: 'chime' },
209
- vibration: true,
210
- bypassDoNotDisturb: true,
211
- repeatAlert: { forMs: 15 * 60 * 1000 }, // repeats until handled, for 15 minutes
212
- timeSensitive: true,
213
- version: 1,
214
- },
215
- ]);
216
- ```
217
-
218
- `name` and `description` are **yours to localize** — the package neither translates nor normalizes them. They are what a person reads when they open notification settings to turn something down, which makes them the only part of a tone a user ever sees.
219
-
220
- ### Alarm-style tones
221
-
222
- `repeatAlert` makes the alert sound **repeat until the user handles the notification**, rather than chiming once. A single chime is easy to miss from across the room, which is exactly the situation a medication or wake-up reminder exists for.
223
-
224
- The duration is **required**, not optional. An alert with no bound means a user who left their phone at home comes back to a device still ringing, so the type makes that state impossible to write. Permitted range: **1 to 60 minutes** (`REPEAT_ALERT_BOUNDS`).
225
-
226
- When the window elapses:
227
-
228
- - **the sound stops**, and
229
- - **the notification stays in the tray.**
230
-
231
- Both halves matter. A notification that silently disappeared is worse than one that never sounded — the user returns to a quiet phone and no idea what they missed. Handling it earlier stops the sound immediately.
232
-
233
- #### One setup step, for repeating tones only
234
-
235
- Expiry almost always falls while your app is not running, so the package needs a handler the platform can start on its own. Register it at **module scope** in `index.js` — not in a component, and not in an effect:
236
-
237
- ```js
238
- // index.js
239
- import { registerBackgroundNotificationHandler } from '@chipmobilesdk/rn-notification';
240
-
241
- registerBackgroundNotificationHandler({ engine });
242
- ```
243
-
244
- Without it, a repeating alert still starts and still stops when the user handles it — but nothing ends it on a phone lying face-down on a table. `apply()` reports `REPEAT_STOP_HANDLER_MISSING` rather than letting you find that out from a user.
245
-
246
- **An app that declares no repeating tone needs none of this.**
247
-
248
- > Pair a repeating tone with `bypassDoNotDisturb: true` and `urgency: 'max'`. An alarm silenced by Do Not Disturb is not an alarm.
249
-
250
- ### What each platform actually does
251
-
252
- | Attribute | Android | iOS |
253
- |---|---|---|
254
- | `repeatAlert` — sound repeats | **Honoured** | **Refused** — reported in `repeatUnavailable` |
255
- | `repeatAlert.forMs` — the bound | **Honoured**, with the handler registered | N/A — nothing to bound |
256
- | Sound stops at expiry, notification stays | **Honoured** | N/A |
257
- | `timeSensitive` | **Honoured** | **Honoured only with your app's own Time Sensitive Notifications capability**; without it iOS delivers at `active` |
258
- | `name` / `description` in settings | **Honoured**, including updates on existing installs | Accepted; iOS has no per-tone settings entry, so they are not displayed |
259
- | `bypassDoNotDisturb` | **Honoured** | **Refused** — reported in `bypassUnavailable` |
260
-
261
- **iOS does not repeat an alert sound at all.** There is no looping API, and the one interruption level that overrides the mute switch requires an entitlement Apple grants by application — which this package does not hold and will not request. The notification still delivers and still sounds once.
262
-
263
- Seven limits that remain true, stated rather than papered over:
264
-
265
- 1. **The declared window is a floor, not a precise stop.** The entry that ends the alert is scheduled like any other notification, so it inherits the same delivery accuracy — within 60 s with exact alarms, up to 15 minutes without. Measured on an emulator without `SCHEDULE_EXACT_ALARM`, a declared 2-minute window actually ran 2m35s and 3m00s. Declare the shortest window that is still useful, and expect it to overrun rather than undershoot.
266
- 2. **Android alarm audio uses the notification volume, not the alarm volume.** A device with notifications muted stays silent. Not fixable without native changes.
267
- 3. **Android stops an insistent sound when the user opens the notification shade.** Platform behaviour — and arguably the right one, since they have now seen it.
268
- 4. **The stop marker is briefly visible in the tray** before it is removed. It runs on a minimum-importance, silent channel; a scheduled notification is the only OS-driven execution available.
269
- 5. **Without the registered background handler, expiry does not happen while the app is dead.**
270
- 6. **Aggressive vendor battery management can delay it**, in the same way and for the same reasons the package already reports `mayBeDelayed` and `unknown`.
271
- 7. **Android restores a channel the user deleted** with its original settings when it is re-declared. No reliable API distinguishes that from a channel that never existed.
272
-
273
- ### When you must bump a tone version
274
-
275
- Android freezes most of a channel's configuration once it exists, so changing one of those is a **migration**: a new channel, and the user loses every setting they had adjusted on the old one.
276
-
277
- | Change | Version bump |
278
- |---|---|
279
- | `name`, `description` | **No** — updated in place |
280
- | `repeatAlert`, `timeSensitive` | **No** — realized per notification |
281
- | `urgency`, `sound`, `vibration`, `badge`, `groupId`, `bypassDoNotDisturb` | **Yes** |
282
-
283
- Correcting a typo or shipping a new translation therefore costs nothing. `apply()` reports it as `labelsRefreshed`, distinct from `created`, `migrated`, and `unchanged`.
284
-
285
- > **Upgrading from 0.1.x:** the first `apply()` refreshes labels once per existing tone, because every existing device currently holds the tone id as its channel name. Every apply after that is zero-operation again. Nothing is recreated or cancelled.
286
-
287
- ## Permissions
288
-
289
- ```ts
290
- const report = await permissions.getPermissions(); // never prompts
291
- await permissions.requestDisplayPermission(); // independent
292
- await permissions.requestExactAlarmPermission(); // independent
293
- await permissions.openSettings('display'); // per-permission
294
- ```
295
-
296
- Six states, never collapsed: `notAsked`, `granted`, `denied`, `permanentlyDenied`, `provisional`, `notRequired`.
297
-
298
- **Ask after intent, never on first launch.** A prompt at the wrong moment is a permanent denial.
299
-
300
- **A refusal never fails your operation.** The user's work completes; the lost capability is reported separately.
301
-
302
- ## Interaction
303
-
304
- ```ts
305
- notifications.onInteraction(async event => {
306
- if (event.kind === 'tap') navigate(event.routing);
307
- });
308
- ```
309
-
310
- 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.
311
-
312
- When the target is gone, call `reportTargetMissing()` and pick your own fallback. No platform error is ever shown to the user.
313
-
314
- ## Push
315
-
316
- ```ts
317
- import { createPushGate } from '@chipmobilesdk/rn-notification/push';
318
- ```
319
-
320
- Kept on its own subpath so an app that never imports it ships **zero** provider code, permissions, or entitlements.
321
-
322
- Foreground pushes are **never auto-displayed** — presentation is your decision.
323
-
324
- ### The payload contract (for backend teams)
325
-
326
- ```jsonc
327
- { "v": 1, "kind": "display", "id": "task:1234:due",
328
- "content": { "title": "…", "body": "…" }, "tone": "reminder",
329
- "routing": { "screen": "task" }, "dedupId": "evt-9f2c" }
330
- ```
331
-
332
- `kind`: `display` | `data` | `schedule` | `cancel`.
333
-
334
- **Append-only discipline — binding, not advisory.** An unrecognized version is processed **best-effort**: known fields honoured, unknown fields ignored. That is safe *only* while a released field's meaning is never repurposed.
335
-
336
- > ⚠️ **Changing what a released field means will be silently misprocessed by every installed app running an older contract version.** Express a semantic change as a **new field**, never a changed one. Adding a field is additive and never breaking.
337
-
338
- Local schedules take precedence over push for the same identifier. A server that wants to override sends `cancel` then `schedule`.
339
-
340
- ## What is persisted
341
-
342
- Six collections, in app-private, backup-excluded storage:
343
-
344
- | Collection | Cap | Retention |
345
- |---|---|---|
346
- | Deduplication ledger | 500 | 24 hours |
347
- | Interaction buffer | 50 | 7 days |
348
- | Migration ledger | 20 | — |
349
- | Series ledger | 32 | — |
350
- | Permission history | 64 | — |
351
- | Last reconcile summary | 1 | — |
352
-
353
- **No notification content, no push token, no desired set.** The interaction buffer is the sole content-bearing exception, and it evicts on delivery.
354
-
355
- 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**.
356
-
357
- ## Testing without a device
358
-
359
- ```ts
360
- import { createFakeEngine, createFakeProvider, runConformanceSuite } from '@chipmobilesdk/rn-notification/testing';
361
- ```
362
-
363
- The fakes simulate virtual time, time-zone travel, process death, boot-restoration gaps, redelivery, and platform truncation — so every decision path is provable with no device, no emulator, and no live provider.
364
-
365
- ## Budgets
366
-
367
- | Item | Limit |
368
- |---|---|
369
- | Identifier | 128 characters (hard) |
370
- | Routing payload | 4 KB (hard) |
371
- | Content | 512 / 3072 / 512 bytes, 3584 total (hard) |
372
- | Actions per notification | 10 |
373
- | iOS pending notifications | 64 (platform) |
374
- | Desired set per pass | 500 (soft) |
375
- | Recurrence series | 32 |
376
- | Repeating alert duration | 1–60 minutes (hard) |
377
-
378
- Each **repeating** notification also holds one extra pending slot on Android, for the entry that ends its alert. That cost is subtracted from the ceiling and reported through the usual truncation path, rather than quietly shrinking how many of your notifications fit. Non-repeating notifications cost nothing extra, and iOS places none at all.
379
-
380
- Delivery accuracy: within **60 s** with exact alarms; within **15 min** without, on a device under no manufacturer restriction. Under aggressive vendor battery management this bound does **not** hold, and the package reports the restriction where it can detect it.
381
-
382
- ## Data behaviour (for your privacy declarations)
383
-
384
- - **Local-only adoption changes no privacy declaration.** State is app-private, backup-excluded, capped, time-evicted, and never transmitted. The package opens **no** network connection.
385
- - **Enabling push does change it.** The registration token is a device-scoped identifier your app sends to your server, and Firebase Cloud Messaging collects device data — both require declaration in Apple App Privacy and Google Play Data safety.
386
- - Disable analytics collection so adoption cannot create an ATT-relevant posture:
387
- `firebase_analytics_collection_enabled=false`, `google_analytics_adid_collection_enabled=false`.
388
- - **Notification content appears on a locked screen.** Keep anything sensitive out of the body and put it in the structured routing payload instead.
389
- - User-generated content delivered through notifications remains **your** moderation obligation.
390
- - Do not use notification content to route around store billing.
391
-
392
- ## Compatibility
393
-
394
- | Item | Supported |
395
- |---|---|
396
- | React | >= 19 |
397
- | React Native | >= 0.85 |
398
- | iOS | >= 15.1 |
399
- | Android | `minSdkVersion` 24, `targetSdkVersion` 36 |
400
- | Node | >= 22.11.0 |
401
-
402
- ## Diagnostic identifiers
403
-
404
- 43 stable identifiers across validation, reconciliation, permissions, channels, interaction, push, state, and volume. Every one is reachable through the fault injector. See `ALL_DIAGNOSTIC_CODES`.
405
-
406
- 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)`.
407
-
408
- ## License
409
-
410
- UNLICENSED — internal ChipMobileSdk package.
1
+ # @chipmobilesdk/rn-notification
2
+
3
+ Shared notification library for bare React Native apps on iOS and Android.
4
+
5
+ > **License:** `UNLICENSED`. Published publicly for use by the owner's
6
+ > applications; no open-source license is granted.
7
+
8
+ Scheduled local notifications with deterministic identity and idempotent reconciliation, provider-agnostic push, permission coordination, declarative channels and tones, action and tap routing across every app state, and a decision core you can test on a laptop.
9
+
10
+ ## Why this exists
11
+
12
+ Every notification feature re-solves the same problems, and most of them are discovered after shipping:
13
+
14
+ - Identifiers must be **derived from data**, not random, or you can never replace a notification you already scheduled.
15
+ - The OS will not hold an unbounded repeating series, and iOS silently discards past **64 pending notifications**.
16
+ - Reconciling "what my data implies" against "what the device holds" must be **idempotent**, or it is untestable.
17
+ - Display permission and exact-alarm permission are **different things**, asked separately, and a refusal must not break the user's actual work.
18
+ - Android channels are **immutable once created** — changing one is a migration, not an assignment.
19
+
20
+ ## Installation
21
+
22
+ ```sh
23
+ npm install @chipmobilesdk/rn-notification
24
+ npm install react-native-notify-kit react-native-mmkv react-native-localize
25
+ cd ios && pod install
26
+ ```
27
+
28
+ ### Peer dependencies
29
+
30
+ | Package | Required | Purpose |
31
+ |---|---|---|
32
+ | `react` >= 19, `react-native` >= 0.85 | yes | Host framework |
33
+ | `react-native-notify-kit` >= 10.5.0 | yes | Local notification engine |
34
+ | `react-native-mmkv` >= 4.0.0 | yes | Bounded persisted state |
35
+ | `react-native-localize` >= 3.7.0 | yes | Platform time zone for re-anchoring |
36
+ | `@react-native-firebase/app` >= 26.0.0 | **optional** | Push only — required *by* messaging |
37
+ | `@react-native-firebase/messaging` >= 26.0.0 | **optional** | Push only |
38
+
39
+ ### Enabling push (optional)
40
+
41
+ > ⚠️ **Install both Firebase packages, and add the config files.** `@react-native-firebase/messaging` will not build without `@react-native-firebase/app`, and `app` will not build without a Firebase config file. Installing messaging alone fails the Android build at configure time with `Could not find the react-native-firebase/app package`.
42
+
43
+ ```sh
44
+ npm install @react-native-firebase/app @react-native-firebase/messaging
45
+ cd ios && pod install
46
+ ```
47
+
48
+ Then add the config files **before building** — they are not optional:
49
+
50
+ - Android → `android/app/google-services.json`, plus the `com.google.gms:google-services` plugin
51
+ - iOS → `ios/<App>/GoogleService-Info.plist`
52
+
53
+ **If you are not enabling push, install none of this.** The package's push code lives behind the `/push` subpath and is never imported by the default entrypoint, so a local-only app ships zero provider code, zero push permissions, and zero entitlements — and takes on none of the provider's data-collection declarations.
54
+
55
+ > **On the engine.** The original design named Notifee. Its repository was archived on 2026-04-07 with its last release in December 2024, so this package uses `react-native-notify-kit` — the maintained, 100%-API-compatible fork Invertase itself points to. The engine sits behind `NotificationEngineAdapter`; nothing consumer-facing depends on which one is in use.
56
+
57
+ ## Native setup
58
+
59
+ ### iOS
60
+
61
+ Notification authorization needs **no `Info.plist` usage string**. Push additionally requires:
62
+
63
+ - the `aps-environment` entitlement
64
+ - the `remote-notification` background mode
65
+
66
+ ### Android
67
+
68
+ | Permission | When | Notes |
69
+ |---|---|---|
70
+ | `POST_NOTIFICATIONS` | API 33+ | Runtime; requested only when you call for it |
71
+ | `RECEIVE_BOOT_COMPLETED` | always | Restores scheduled notifications after a restart |
72
+ | `SCHEDULE_EXACT_ALARM` | **opt-in** | You add it; requires a Play Console declaration |
73
+
74
+ **Never use `USE_EXACT_ALARM` unless your app *is* an alarm clock, timer, or calendar.** Google Play restricts it and will disallow publishing for apps that do not qualify.
75
+
76
+ #### ⚠️ You must actively *remove* `SCHEDULE_EXACT_ALARM` if you do not need it
77
+
78
+ This package declares no exact-alarm permission — but **the notification engine does**, unconditionally, in its own manifest. Android's manifest merger is transitive, so it lands in your merged manifest whether you asked for it or not. Verified on a real build: `SCHEDULE_EXACT_ALARM` appears in the installed package even when nothing opts in.
79
+
80
+ `SCHEDULE_EXACT_ALARM` requires a **Play Console Restricted Permissions declaration**. If your app does not need precise timing, strip it:
81
+
82
+ ```xml
83
+ <!-- android/app/src/main/AndroidManifest.xml -->
84
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
85
+ xmlns:tools="http://schemas.android.com/tools">
86
+
87
+ <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
88
+ tools:node="remove" />
89
+ ```
90
+
91
+ Without exact alarms, notifications are still scheduled — in approximate mode, with `mayBeDelayed: true` on the result. Nothing breaks.
92
+
93
+ **Verify after building**, because a merged manifest is the only thing that tells the truth:
94
+
95
+ ```sh
96
+ adb shell dumpsys package <your.package.id> | grep -E "android\.permission\.(SCHEDULE|USE)_EXACT_ALARM"
97
+ # no output = correctly removed
98
+ ```
99
+
100
+ > Match on `android.permission.` specifically. A loose `grep -i EXACT_ALARM` also hits
101
+ > `android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` — a broadcast the
102
+ > engine legitimately listens for, not a permission — and reports a false failure.
103
+
104
+ The engine also contributes `WAKE_LOCK`, `VIBRATE`, `FOREGROUND_SERVICE`, `INTERNET`, and `ACCESS_NETWORK_STATE`. None is store-restricted, but review them against your own privacy declarations — and remove any you can prove unnecessary using the same `tools:node="remove"` mechanism.
105
+
106
+ ### Backup exclusion
107
+
108
+ State lives under `Library/Caches` on iOS (backup-excluded by platform design). On Android, add to your `data_extraction_rules.xml` and `full_backup_content`:
109
+
110
+ ```xml
111
+ <exclude domain="file" path="mmkv/chipmobilesdk.notification" />
112
+ ```
113
+
114
+ ## Choosing identifiers
115
+
116
+ Identifiers are **yours**. The package never generates one, because only you can derive a reproducible identifier from your own data.
117
+
118
+ ```ts
119
+ const id = `task:${task.id}:reminder:${index}`; // ✅ derived, reproducible
120
+ const id = crypto.randomUUID(); // ❌ you can never replace it
121
+ ```
122
+
123
+ **Charset**: `A–Z a–z 0–9` and `. _ - : /`, at most 128 characters. Whitespace and control characters are rejected.
124
+
125
+ **Nothing is normalized.** `Task:1` and `task:1` are different identifiers. That is deliberate — silently folding them would hide a bug — but it is also how you accidentally create two notifications for one subject. Derive identifiers from a single canonical source.
126
+
127
+ Owner tags and group tags follow the same rule.
128
+
129
+ ## Choosing an anchoring mode
130
+
131
+ ```ts
132
+ moment: { at } // wall-clock (default)
133
+ moment: { at, anchoring: 'absolute' } // fixed instant worldwide
134
+ ```
135
+
136
+ **Wall-clock** means the local clock time you named. An 08:00 reminder stays 08:00 after your user flies to Tokyo. This is the default because reminders are wall-clock things.
137
+
138
+ **Absolute** means one instant everywhere — a match kickoff, a market open.
139
+
140
+ ### What re-anchoring cannot fix
141
+
142
+ The package re-anchors wall-clock notifications when it notices the device zone changed. It notices during maintenance, which runs while the app is running.
143
+
144
+ **If a wall-clock notification's moment arrives while the app has not run since the zone changed, it fires at the previously anchored instant.** This is stated rather than hidden: every pending entry carries an `anchoringGeneration`, so you can detect and explain the condition instead of being told nothing happened.
145
+
146
+ ## Maintenance versus reconciliation
147
+
148
+ | The package does automatically | You call explicitly |
149
+ |---|---|
150
+ | Applies `config.tones` (and any channel migration) during `initialize()` | `reconcile()` — the desired set |
151
+ | Runs every step on `runtime.registry` when the app returns to the foreground — with the trigger `timeZoneChange` when the device zone moved meanwhile | |
152
+
153
+ The steps themselves need inputs only your app has — the series rules it registered, the identifiers it expects, its permission module — so you register them once on `runtime.registry` after `initialize()`:
154
+
155
+ ```ts
156
+ const { runtime } = await initialize(config, { engine, store });
157
+ runtime.registry.register(createReAnchorStep({ engine, timeZone, resolveTone, canScheduleExactly }));
158
+ runtime.registry.register(createWindowRefreshStep({ recurrence, registeredRules: () => myRules }));
159
+ runtime.registry.register(createVerifyStep({ engine, expectedIds: () => myExpectedIds }));
160
+ runtime.registry.register(createPermissionRefreshStep({ permissions }));
161
+ // On teardown (tests, a sign-out that rebuilds the runtime):
162
+ runtime.dispose();
163
+ ```
164
+
165
+ Before 0.4.0 nothing triggered these steps; they ran only when the app called `runMaintenance()` itself. A cycle that throws is reported as `MAINTENANCE_FAILED`, because nothing awaits it.
166
+
167
+ **Reconciliation never runs on its own.** Only you know what your data implies; a package that guessed would cancel notifications it does not understand.
168
+
169
+ **Automatic maintenance is strictly additive.** It may act only on recurrence series the package owns, and can *never* cancel a notification you scheduled directly. That rule is enforced in code (`MaintenanceScopeViolation`), not merely documented.
170
+
171
+ Call `reconcile()` after: mutating data that implies notifications, sign-in, sign-out, display permission becoming granted, **a device restart**, and app start.
172
+
173
+ ## Reconciliation
174
+
175
+ ```ts
176
+ const result = await reconciler.reconcile({ domain: 'tasks', desired });
177
+ // result.operationCount === 0 on an unchanged second pass
178
+ ```
179
+
180
+ - **Idempotent** — a second consecutive pass with unchanged input performs **zero** platform operations.
181
+ - **Domain-scoped** — never cancels outside `domain`, including notifications from other libraries.
182
+ - **Duplicates**: every copy of a repeated identifier is rejected; anything already scheduled under it is **left in place**. A rejection is not a cancellation.
183
+ - **Truncation**: over the platform ceiling, the furthest-out entries are dropped and *reported*. They return as nearer ones deliver.
184
+
185
+ ## Recurrence
186
+
187
+ ```ts
188
+ await recurrence.registerSeries({
189
+ id: 'series:medication',
190
+ pattern: { kind: 'daily', hour: 8, minute: 0 },
191
+ end: { kind: 'unbounded' },
192
+ template: { content: { title: 'Medication', body: 'Time for your dose' }, tone: 'reminder' },
193
+ });
194
+ ```
195
+
196
+ Default window: **60 days or 32 occurrences per series**, whichever comes first. Bounded by both because either alone fails — a duration alone lets a frequent series exhaust the platform ceiling; a count alone leaves an annual series scheduled a decade out.
197
+
198
+ Months lacking the requested day are **skipped, not clamped**: day 31 means day 31.
199
+
200
+ ## Tones and channels
201
+
202
+ A tone is declared once and mapped to each platform, so your code contains no platform branches.
203
+
204
+ ```ts
205
+ await channels.apply([
206
+ {
207
+ id: 'medication',
208
+ name: t('notifications.medication.name'), // shown in system settings
209
+ description: t('notifications.medication.hint'),
210
+ urgency: 'max',
211
+ sound: { name: 'chime' },
212
+ vibration: true,
213
+ bypassDoNotDisturb: true,
214
+ repeatAlert: { forMs: 15 * 60 * 1000 }, // repeats until handled, for 15 minutes
215
+ timeSensitive: true,
216
+ version: 1,
217
+ },
218
+ ]);
219
+ ```
220
+
221
+ `name` and `description` are **yours to localize** — the package neither translates nor normalizes them. They are what a person reads when they open notification settings to turn something down, which makes them the only part of a tone a user ever sees.
222
+
223
+ ### Alarm-style tones
224
+
225
+ `repeatAlert` makes the alert sound **repeat until the user handles the notification**, rather than chiming once. A single chime is easy to miss from across the room, which is exactly the situation a medication or wake-up reminder exists for.
226
+
227
+ The duration is **required**, not optional. An alert with no bound means a user who left their phone at home comes back to a device still ringing, so the type makes that state impossible to write. Permitted range: **1 to 60 minutes** (`REPEAT_ALERT_BOUNDS`).
228
+
229
+ When the window elapses:
230
+
231
+ - **the sound stops**, and
232
+ - **the notification stays in the tray.**
233
+
234
+ Both halves matter. A notification that silently disappeared is worse than one that never sounded — the user returns to a quiet phone and no idea what they missed. Handling it earlier stops the sound immediately.
235
+
236
+ #### One setup step, for repeating tones only
237
+
238
+ Expiry almost always falls while your app is not running, so the package needs a handler the platform can start on its own. Register it at **module scope** in `index.js` — not in a component, and not in an effect:
239
+
240
+ ```js
241
+ // index.js
242
+ import { registerBackgroundNotificationHandler } from '@chipmobilesdk/rn-notification';
243
+
244
+ registerBackgroundNotificationHandler({ engine });
245
+ ```
246
+
247
+ Without it, a repeating alert still starts and still stops when the user handles it — but nothing ends it on a phone lying face-down on a table. `apply()` reports `REPEAT_STOP_HANDLER_MISSING` rather than letting you find that out from a user.
248
+
249
+ **An app that declares no repeating tone needs none of this.**
250
+
251
+ > Pair a repeating tone with `bypassDoNotDisturb: true` and `urgency: 'max'`. An alarm silenced by Do Not Disturb is not an alarm.
252
+
253
+ ### What each platform actually does
254
+
255
+ | Attribute | Android | iOS |
256
+ |---|---|---|
257
+ | `repeatAlert` — sound repeats | **Honoured** | **Refused** — reported in `repeatUnavailable` |
258
+ | `repeatAlert.forMs` — the bound | **Honoured**, with the handler registered | N/A — nothing to bound |
259
+ | Sound stops at expiry, notification stays | **Honoured** | N/A |
260
+ | `timeSensitive` | **Honoured** | **Honoured only with your app's own Time Sensitive Notifications capability**; without it iOS delivers at `active` |
261
+ | `name` / `description` in settings | **Honoured**, including updates on existing installs | Accepted; iOS has no per-tone settings entry, so they are not displayed |
262
+ | `bypassDoNotDisturb` | **Honoured** | **Refused** — reported in `bypassUnavailable` |
263
+
264
+ **iOS does not repeat an alert sound at all.** There is no looping API, and the one interruption level that overrides the mute switch requires an entitlement Apple grants by application — which this package does not hold and will not request. The notification still delivers and still sounds once.
265
+
266
+ Seven limits that remain true, stated rather than papered over:
267
+
268
+ 1. **The declared window is a floor, not a precise stop.** The entry that ends the alert is scheduled like any other notification, so it inherits the same delivery accuracy — within 60 s with exact alarms, up to 15 minutes without. Measured on an emulator without `SCHEDULE_EXACT_ALARM`, a declared 2-minute window actually ran 2m35s and 3m00s. Declare the shortest window that is still useful, and expect it to overrun rather than undershoot.
269
+ 2. **Android alarm audio uses the notification volume, not the alarm volume.** A device with notifications muted stays silent. Not fixable without native changes.
270
+ 3. **Android stops an insistent sound when the user opens the notification shade.** Platform behaviour — and arguably the right one, since they have now seen it.
271
+ 4. **The stop marker is briefly visible in the tray** before it is removed. It runs on a minimum-importance, silent channel; a scheduled notification is the only OS-driven execution available.
272
+ 5. **Without the registered background handler, expiry does not happen while the app is dead.**
273
+ 6. **Aggressive vendor battery management can delay it**, in the same way and for the same reasons the package already reports `mayBeDelayed` and `unknown`.
274
+ 7. **Android restores a channel the user deleted** with its original settings when it is re-declared. No reliable API distinguishes that from a channel that never existed.
275
+
276
+ ### When you must bump a tone version
277
+
278
+ Android freezes most of a channel's configuration once it exists, so changing one of those is a **migration**: a new channel, and the user loses every setting they had adjusted on the old one.
279
+
280
+ | Change | Version bump |
281
+ |---|---|
282
+ | `name`, `description` | **No** — updated in place |
283
+ | `repeatAlert`, `timeSensitive` | **No** — realized per notification |
284
+ | `urgency`, `sound`, `vibration`, `badge`, `groupId`, `bypassDoNotDisturb` | **Yes** |
285
+
286
+ Correcting a typo or shipping a new translation therefore costs nothing. `apply()` reports it as `labelsRefreshed`, distinct from `created`, `migrated`, and `unchanged`.
287
+
288
+ > **Upgrading from 0.1.x:** the first `apply()` refreshes labels once per existing tone, because every existing device currently holds the tone id as its channel name. Every apply after that is zero-operation again. Nothing is recreated or cancelled.
289
+
290
+ ## Permissions
291
+
292
+ ```ts
293
+ const report = await permissions.getPermissions(); // never prompts
294
+ await permissions.requestDisplayPermission(); // independent
295
+ await permissions.requestExactAlarmPermission(); // independent
296
+ await permissions.openSettings('display'); // per-permission
297
+ ```
298
+
299
+ Six states, never collapsed: `notAsked`, `granted`, `denied`, `permanentlyDenied`, `provisional`, `notRequired`.
300
+
301
+ **Ask after intent, never on first launch.** A prompt at the wrong moment is a permanent denial.
302
+
303
+ **A refusal never fails your operation.** The user's work completes; the lost capability is reported separately.
304
+
305
+ ## Interaction
306
+
307
+ ```ts
308
+ notifications.onInteraction(async event => {
309
+ if (event.kind === 'tap') navigate(event.routing);
310
+ });
311
+ ```
312
+
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
+
315
+ When the target is gone, call `reportTargetMissing()` and pick your own fallback. No platform error is ever shown to the user.
316
+
317
+ ## Push
318
+
319
+ ```ts
320
+ import { createPushGate } from '@chipmobilesdk/rn-notification/push';
321
+ ```
322
+
323
+ Kept on its own subpath so an app that never imports it ships **zero** provider code, permissions, or entitlements.
324
+
325
+ Foreground pushes are **never auto-displayed** — presentation is your decision.
326
+
327
+ ### The payload contract (for backend teams)
328
+
329
+ ```jsonc
330
+ { "v": 1, "kind": "display", "id": "task:1234:due",
331
+ "content": { "title": "…", "body": "…" }, "tone": "reminder",
332
+ "routing": { "screen": "task" }, "dedupId": "evt-9f2c" }
333
+ ```
334
+
335
+ `kind`: `display` | `data` | `schedule` | `cancel`.
336
+
337
+ **Append-only discipline — binding, not advisory.** An unrecognized version is processed **best-effort**: known fields honoured, unknown fields ignored. That is safe *only* while a released field's meaning is never repurposed.
338
+
339
+ > ⚠️ **Changing what a released field means will be silently misprocessed by every installed app running an older contract version.** Express a semantic change as a **new field**, never a changed one. Adding a field is additive and never breaking.
340
+
341
+ Local schedules take precedence over push for the same identifier. A server that wants to override sends `cancel` then `schedule`.
342
+
343
+ ## What is persisted
344
+
345
+ Six collections, in app-private, backup-excluded storage:
346
+
347
+ | Collection | Cap | Retention |
348
+ |---|---|---|
349
+ | Deduplication ledger | 500 | 24 hours |
350
+ | Interaction buffer | 50 | 7 days |
351
+ | Migration ledger | 20 | — |
352
+ | Series ledger | 32 | — |
353
+ | Permission history | 64 | — |
354
+ | Last reconcile summary | 1 | — |
355
+
356
+ **No notification content, no push token, no desired set.** The interaction buffer is the sole content-bearing exception, and it evicts on delivery.
357
+
358
+ 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
+
360
+ ## Testing without a device
361
+
362
+ ```ts
363
+ import { createFakeEngine, createFakeProvider, runConformanceSuite } from '@chipmobilesdk/rn-notification/testing';
364
+ ```
365
+
366
+ The fakes simulate virtual time, time-zone travel, process death, boot-restoration gaps, redelivery, and platform truncation — so every decision path is provable with no device, no emulator, and no live provider.
367
+
368
+ ## Budgets
369
+
370
+ | Item | Limit |
371
+ |---|---|
372
+ | Identifier | 128 characters (hard) |
373
+ | Routing payload | 4 KB (hard) |
374
+ | Content | 512 / 3072 / 512 bytes, 3584 total (hard) |
375
+ | Actions per notification | 10 |
376
+ | iOS pending notifications | 64 (platform) |
377
+ | Desired set per pass | 500 (soft) |
378
+ | Recurrence series | 32 |
379
+ | Repeating alert duration | 1–60 minutes (hard) |
380
+
381
+ Each **repeating** notification also holds one extra pending slot on Android, for the entry that ends its alert. That cost is subtracted from the ceiling and reported through the usual truncation path, rather than quietly shrinking how many of your notifications fit. Non-repeating notifications cost nothing extra, and iOS places none at all.
382
+
383
+ Delivery accuracy: within **60 s** with exact alarms; within **15 min** without, on a device under no manufacturer restriction. Under aggressive vendor battery management this bound does **not** hold, and the package reports the restriction where it can detect it.
384
+
385
+ ## Data behaviour (for your privacy declarations)
386
+
387
+ - **Local-only adoption changes no privacy declaration.** State is app-private, backup-excluded, capped, time-evicted, and never transmitted. The package opens **no** network connection.
388
+ - **Enabling push does change it.** The registration token is a device-scoped identifier your app sends to your server, and Firebase Cloud Messaging collects device data — both require declaration in Apple App Privacy and Google Play Data safety.
389
+ - Disable analytics collection so adoption cannot create an ATT-relevant posture:
390
+ `firebase_analytics_collection_enabled=false`, `google_analytics_adid_collection_enabled=false`.
391
+ - **Notification content appears on a locked screen.** Keep anything sensitive out of the body and put it in the structured routing payload instead.
392
+ - User-generated content delivered through notifications remains **your** moderation obligation.
393
+ - Do not use notification content to route around store billing.
394
+
395
+ ## Compatibility
396
+
397
+ | Item | Supported |
398
+ |---|---|
399
+ | React | >= 19 |
400
+ | React Native | >= 0.85 |
401
+ | iOS | >= 15.1 |
402
+ | Android | `minSdkVersion` 24, `targetSdkVersion` 36 |
403
+ | Node | >= 22.11.0 |
404
+
405
+ ## Diagnostic identifiers
406
+
407
+ 43 stable identifiers across validation, reconciliation, permissions, channels, interaction, push, state, and volume. Every one is reachable through the fault injector. See `ALL_DIAGNOSTIC_CODES`.
408
+
409
+ 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
+
411
+ ## License
412
+
413
+ `UNLICENSED`. Published publicly for use by the owner's applications; no
414
+ open-source license is granted.