@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +306 -0
  3. package/package.json +73 -0
  4. package/src/channel/migrate.ts +211 -0
  5. package/src/channel/tone-map.ts +90 -0
  6. package/src/contract/channel.ts +89 -0
  7. package/src/contract/diagnostics.ts +102 -0
  8. package/src/contract/errors.ts +123 -0
  9. package/src/contract/index.ts +10 -0
  10. package/src/contract/interaction.ts +62 -0
  11. package/src/contract/notification.ts +118 -0
  12. package/src/contract/permission.ts +60 -0
  13. package/src/contract/reconcile.ts +88 -0
  14. package/src/contract/recurrence.ts +123 -0
  15. package/src/diagnostics/burst.ts +101 -0
  16. package/src/diagnostics/events.ts +72 -0
  17. package/src/diagnostics/redact.ts +72 -0
  18. package/src/diagnostics/snapshot.ts +64 -0
  19. package/src/engine/adapter.ts +79 -0
  20. package/src/engine/notify-kit-engine.ts +535 -0
  21. package/src/index.ts +173 -0
  22. package/src/initialize.ts +174 -0
  23. package/src/interaction/index.ts +146 -0
  24. package/src/maintenance/cycle.ts +147 -0
  25. package/src/maintenance/permission-refresh.ts +41 -0
  26. package/src/maintenance/reanchor.ts +95 -0
  27. package/src/maintenance/toggle.ts +60 -0
  28. package/src/maintenance/verify.ts +83 -0
  29. package/src/maintenance/window-refresh.ts +61 -0
  30. package/src/permission/index.ts +183 -0
  31. package/src/permission/state.ts +77 -0
  32. package/src/push/envelope.ts +165 -0
  33. package/src/push/fcm-provider.ts +146 -0
  34. package/src/push/gate.ts +190 -0
  35. package/src/push/index.ts +29 -0
  36. package/src/push/instructions.ts +109 -0
  37. package/src/push/provider-adapter.ts +54 -0
  38. package/src/push/signout.ts +65 -0
  39. package/src/reconcile/chunk.ts +53 -0
  40. package/src/reconcile/diff.ts +145 -0
  41. package/src/reconcile/duplicates.ts +73 -0
  42. package/src/reconcile/index.ts +175 -0
  43. package/src/reconcile/truncate.ts +56 -0
  44. package/src/recurrence/clock-guard.ts +71 -0
  45. package/src/recurrence/expand.ts +292 -0
  46. package/src/recurrence/index.ts +177 -0
  47. package/src/schedule/anchor.ts +267 -0
  48. package/src/schedule/index.ts +173 -0
  49. package/src/schedule/partition.ts +85 -0
  50. package/src/schedule/resolve-moment.ts +80 -0
  51. package/src/schedule/validate-content.ts +111 -0
  52. package/src/schedule/validate-identifier.ts +114 -0
  53. package/src/state/caps.ts +71 -0
  54. package/src/state/dedup-ledger.ts +39 -0
  55. package/src/state/interaction-buffer.ts +73 -0
  56. package/src/state/mmkv-store.ts +71 -0
  57. package/src/state/permission-history.ts +41 -0
  58. package/src/state/record.ts +246 -0
  59. package/src/state/reset.ts +41 -0
  60. package/src/state/series-ledger.ts +84 -0
  61. package/src/state/store.ts +40 -0
  62. package/src/testing/conformance/index.ts +194 -0
  63. package/src/testing/fake-engine.ts +284 -0
  64. package/src/testing/fake-provider.ts +129 -0
  65. package/src/testing/faults.ts +72 -0
  66. package/src/testing/index.ts +26 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@chipmobilesdk/rn-notification` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased] — 0.1.0
8
+
9
+ First release. New package, no existing consumers, no migration burden.
10
+
11
+ ### Added
12
+
13
+ - **Scheduling** — caller-supplied identifiers, replacement semantics, a two-sided 60-second tolerance window, and full-detail pending reads that let a consumer diff the device against its own intent.
14
+ - **Time anchoring** — wall-clock (default) and absolute modes, with re-anchoring on device time-zone change and an `anchoringGeneration` on every entry so the one case re-anchoring cannot fix stays detectable.
15
+ - **Reconciliation** — desired-set convergence that is idempotent, domain-scoped, chunked, and serialized against itself.
16
+ - **Recurrence** — declare a rule once; the package maintains a bounded 60-day / 32-occurrence window and refreshes it, with deterministic daylight-saving resolution and clock-discontinuity bounding.
17
+ - **Permissions** — six states that never collapse, independent display and exact-alarm requests, per-permission settings deep links, external-change detection, and manufacturer restriction reporting that admits `unknown`.
18
+ - **Channels and tones** — one declarative configuration mapped to each platform, with a real migration path for Android's immutable channels.
19
+ - **Interaction** — one handler for foreground, background, and cold start, backed by a durable buffer that survives process death.
20
+ - **Push** — a provider-agnostic gate with a normalized envelope, token lifecycle, deduplication across process death, and best-effort handling of newer payload versions.
21
+ - **Observability** — a structured event stream with content redaction, a state snapshot, burst detection, fault injection, and a shared conformance suite.
22
+
23
+ ### Notes
24
+
25
+ - **Local engine substitution — approved 2026-08-16.** The specification named Notifee as the default engine. Its repository was archived on 2026-04-07 with its last release in December 2024, failing the constitution's actively-maintained-dependency requirement. This package uses `react-native-notify-kit`, the maintained 100%-API-compatible fork, **confirmed by the project owner**. The engine sits behind `NotificationEngineAdapter` and no consumer-facing behaviour depends on the choice — this substitution changed no functional requirement, success criterion, or user story. `expo-notifications` is the recorded migration target should the fork stall.
26
+ - **Push is an optional peer.** An app that never imports `/push` ships no provider code, no push permissions, and no entitlements, and takes on none of the provider's data-collection declarations.
27
+ - **No exact-alarm permission is declared by this package.** Consumers opt in; without it, notifications are scheduled in approximate mode with an explicit `mayBeDelayed` flag. `USE_EXACT_ALARM` is never recommended — Google Play restricts it to alarm, timer, and calendar apps.
28
+
29
+ ### Stability
30
+
31
+ | Surface | Stability |
32
+ |---|---|
33
+ | Identifier rules | Stable — breaking to change (persisted by the platform on user devices) |
34
+ | Routing payload contract | Stable — breaking to change |
35
+ | Push payload contract | Stable — **append-only**; repurposing a released field is a contract violation, not a version bump |
36
+ | Scheduling, reconciliation, permissions, interaction | Stable — additive minor releases only |
37
+ | Adapter ports | Experimental — may gain members in minor releases |
38
+ | Persisted state schema | Private — incompatible versions are discarded and rebuilt, never migrated |
39
+
40
+ ### Reserved for additive minor releases
41
+
42
+ Additional push provider adapters, additional recurrence kinds, additional tone attributes, additional diagnostic identifiers, a consumer-supplied storage port, and in-app notification presentation.
package/README.md ADDED
@@ -0,0 +1,306 @@
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
+ | Lookahead window refresh | `reconcile()` — the desired set |
148
+ | Recurrence expansion | |
149
+ | Wall-clock re-anchoring | |
150
+ | Pending-set verification (reports only) | |
151
+ | Permission re-read | |
152
+
153
+ **Reconciliation never runs on its own.** Only you know what your data implies; a package that guessed would cancel notifications it does not understand.
154
+
155
+ **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.
156
+
157
+ Call `reconcile()` after: mutating data that implies notifications, sign-in, sign-out, display permission becoming granted, **a device restart**, and app start.
158
+
159
+ ## Reconciliation
160
+
161
+ ```ts
162
+ const result = await reconciler.reconcile({ domain: 'tasks', desired });
163
+ // result.operationCount === 0 on an unchanged second pass
164
+ ```
165
+
166
+ - **Idempotent** — a second consecutive pass with unchanged input performs **zero** platform operations.
167
+ - **Domain-scoped** — never cancels outside `domain`, including notifications from other libraries.
168
+ - **Duplicates**: every copy of a repeated identifier is rejected; anything already scheduled under it is **left in place**. A rejection is not a cancellation.
169
+ - **Truncation**: over the platform ceiling, the furthest-out entries are dropped and *reported*. They return as nearer ones deliver.
170
+
171
+ ## Recurrence
172
+
173
+ ```ts
174
+ await recurrence.registerSeries({
175
+ id: 'series:medication',
176
+ pattern: { kind: 'daily', hour: 8, minute: 0 },
177
+ end: { kind: 'unbounded' },
178
+ template: { content: { title: 'Medication', body: 'Time for your dose' }, tone: 'reminder' },
179
+ });
180
+ ```
181
+
182
+ 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.
183
+
184
+ Months lacking the requested day are **skipped, not clamped**: day 31 means day 31.
185
+
186
+ ## Permissions
187
+
188
+ ```ts
189
+ const report = await permissions.getPermissions(); // never prompts
190
+ await permissions.requestDisplayPermission(); // independent
191
+ await permissions.requestExactAlarmPermission(); // independent
192
+ await permissions.openSettings('display'); // per-permission
193
+ ```
194
+
195
+ Six states, never collapsed: `notAsked`, `granted`, `denied`, `permanentlyDenied`, `provisional`, `notRequired`.
196
+
197
+ **Ask after intent, never on first launch.** A prompt at the wrong moment is a permanent denial.
198
+
199
+ **A refusal never fails your operation.** The user's work completes; the lost capability is reported separately.
200
+
201
+ ## Interaction
202
+
203
+ ```ts
204
+ notifications.onInteraction(async event => {
205
+ if (event.kind === 'tap') navigate(event.routing);
206
+ });
207
+ ```
208
+
209
+ 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.
210
+
211
+ When the target is gone, call `reportTargetMissing()` and pick your own fallback. No platform error is ever shown to the user.
212
+
213
+ ## Push
214
+
215
+ ```ts
216
+ import { createPushGate } from '@chipmobilesdk/rn-notification/push';
217
+ ```
218
+
219
+ Kept on its own subpath so an app that never imports it ships **zero** provider code, permissions, or entitlements.
220
+
221
+ Foreground pushes are **never auto-displayed** — presentation is your decision.
222
+
223
+ ### The payload contract (for backend teams)
224
+
225
+ ```jsonc
226
+ { "v": 1, "kind": "display", "id": "task:1234:due",
227
+ "content": { "title": "…", "body": "…" }, "tone": "reminder",
228
+ "routing": { "screen": "task" }, "dedupId": "evt-9f2c" }
229
+ ```
230
+
231
+ `kind`: `display` | `data` | `schedule` | `cancel`.
232
+
233
+ **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.
234
+
235
+ > ⚠️ **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.
236
+
237
+ Local schedules take precedence over push for the same identifier. A server that wants to override sends `cancel` then `schedule`.
238
+
239
+ ## What is persisted
240
+
241
+ Six collections, in app-private, backup-excluded storage:
242
+
243
+ | Collection | Cap | Retention |
244
+ |---|---|---|
245
+ | Deduplication ledger | 500 | 24 hours |
246
+ | Interaction buffer | 50 | 7 days |
247
+ | Migration ledger | 20 | — |
248
+ | Series ledger | 32 | — |
249
+ | Permission history | 64 | — |
250
+ | Last reconcile summary | 1 | — |
251
+
252
+ **No notification content, no push token, no desired set.** The interaction buffer is the sole content-bearing exception, and it evicts on delivery.
253
+
254
+ 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**.
255
+
256
+ ## Testing without a device
257
+
258
+ ```ts
259
+ import { createFakeEngine, createFakeProvider, runConformanceSuite } from '@chipmobilesdk/rn-notification/testing';
260
+ ```
261
+
262
+ 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.
263
+
264
+ ## Budgets
265
+
266
+ | Item | Limit |
267
+ |---|---|
268
+ | Identifier | 128 characters (hard) |
269
+ | Routing payload | 4 KB (hard) |
270
+ | Content | 512 / 3072 / 512 bytes, 3584 total (hard) |
271
+ | Actions per notification | 10 |
272
+ | iOS pending notifications | 64 (platform) |
273
+ | Desired set per pass | 500 (soft) |
274
+ | Recurrence series | 32 |
275
+
276
+ 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.
277
+
278
+ ## Data behaviour (for your privacy declarations)
279
+
280
+ - **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.
281
+ - **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.
282
+ - Disable analytics collection so adoption cannot create an ATT-relevant posture:
283
+ `firebase_analytics_collection_enabled=false`, `google_analytics_adid_collection_enabled=false`.
284
+ - **Notification content appears on a locked screen.** Keep anything sensitive out of the body and put it in the structured routing payload instead.
285
+ - User-generated content delivered through notifications remains **your** moderation obligation.
286
+ - Do not use notification content to route around store billing.
287
+
288
+ ## Compatibility
289
+
290
+ | Item | Supported |
291
+ |---|---|
292
+ | React | >= 19 |
293
+ | React Native | >= 0.85 |
294
+ | iOS | >= 15.1 |
295
+ | Android | `minSdkVersion` 24, `targetSdkVersion` 36 |
296
+ | Node | >= 22.11.0 |
297
+
298
+ ## Diagnostic identifiers
299
+
300
+ 39 stable identifiers across validation, reconciliation, permissions, channels, interaction, push, state, and volume. Every one is reachable through the fault injector. See `ALL_DIAGNOSTIC_CODES`.
301
+
302
+ 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`. Use `isNonFailure(code)`.
303
+
304
+ ## License
305
+
306
+ UNLICENSED — internal ChipMobileSdk package.
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@chipmobilesdk/rn-notification",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "react-native": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ },
14
+ "./push": {
15
+ "types": "./src/push/index.ts",
16
+ "react-native": "./src/push/index.ts",
17
+ "default": "./src/push/index.ts"
18
+ },
19
+ "./testing": {
20
+ "types": "./src/testing/index.ts",
21
+ "react-native": "./src/testing/index.ts",
22
+ "default": "./src/testing/index.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "src/index.ts",
27
+ "src/initialize.ts",
28
+ "src/channel",
29
+ "src/contract",
30
+ "src/diagnostics",
31
+ "src/engine",
32
+ "src/interaction",
33
+ "src/maintenance",
34
+ "src/permission",
35
+ "src/push",
36
+ "src/recurrence",
37
+ "src/reconcile",
38
+ "src/schedule",
39
+ "src/state",
40
+ "src/testing",
41
+ "README.md",
42
+ "CHANGELOG.md"
43
+ ],
44
+ "sideEffects": false,
45
+ "scripts": {
46
+ "typecheck": "tsc -p tsconfig.json --noEmit",
47
+ "pack:dry-run": "npm pack --dry-run"
48
+ },
49
+ "peerDependencies": {
50
+ "@react-native-firebase/app": ">=26.0.0",
51
+ "@react-native-firebase/messaging": ">=26.0.0",
52
+ "react": ">=19",
53
+ "react-native": ">=0.85",
54
+ "react-native-localize": ">=3.7.0",
55
+ "react-native-mmkv": ">=4.0.0",
56
+ "react-native-notify-kit": ">=10.5.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@react-native-firebase/app": {
60
+ "optional": true
61
+ },
62
+ "@react-native-firebase/messaging": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "engines": {
67
+ "node": ">=22.11.0"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public",
71
+ "registry": "https://registry.npmjs.org/"
72
+ }
73
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Channel declaration and migration.
3
+ *
4
+ * Android channels are immutable once created: declaring a channel whose id
5
+ * already exists silently keeps the original configuration. That single fact is
6
+ * why changing a tone is a **migration** rather than an assignment, and why
7
+ * this package keeps its own ledger — the platform will not tell you which
8
+ * version of a channel a device already holds.
9
+ *
10
+ * Migration creates the new channel, re-homes notifications still pointing at
11
+ * the old one, and only then retires it. That order matters: retiring first
12
+ * would leave live notifications attached to a channel that no longer exists.
13
+ */
14
+
15
+ import type {
16
+ ChannelApplyResult,
17
+ ChannelGroup,
18
+ ToneDeclaration,
19
+ } from '../contract/channel';
20
+ import type { NotificationEngineAdapter } from '../engine/adapter';
21
+ import type { StateManager, MigrationEntry } from '../state/record';
22
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
23
+ import { channelIdFor, resolveTone } from './tone-map';
24
+
25
+ export interface ChannelDependencies {
26
+ readonly engine: NotificationEngineAdapter;
27
+ readonly state: StateManager;
28
+ readonly now: () => number;
29
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
30
+ }
31
+
32
+ function ledgerFor(state: StateManager, toneId: string): MigrationEntry | undefined {
33
+ return state.read().migrationLedger.find(entry => entry.toneId === toneId && !entry.retired);
34
+ }
35
+
36
+ function recordApplied(
37
+ state: StateManager,
38
+ tone: ToneDeclaration,
39
+ now: number,
40
+ retiredChannelId?: string,
41
+ ): void {
42
+ state.update('migrationLedger', current => {
43
+ const withoutTone = current.filter(entry => entry.toneId !== tone.id);
44
+ const retired =
45
+ retiredChannelId === undefined
46
+ ? []
47
+ : [
48
+ {
49
+ at: now,
50
+ toneId: tone.id,
51
+ appliedVersion: 0,
52
+ channelId: retiredChannelId,
53
+ retired: true,
54
+ },
55
+ ];
56
+
57
+ return [
58
+ ...withoutTone,
59
+ ...retired,
60
+ {
61
+ at: now,
62
+ toneId: tone.id,
63
+ appliedVersion: tone.version,
64
+ channelId: channelIdFor(tone.id, tone.version),
65
+ retired: false,
66
+ },
67
+ ];
68
+ });
69
+ }
70
+
71
+ export function createChannels(deps: ChannelDependencies) {
72
+ const emit = deps.emit ?? (() => {});
73
+
74
+ return {
75
+ /**
76
+ * Applies a tone configuration, migrating any tone whose version moved.
77
+ *
78
+ * Applying an unchanged configuration performs no work at all: no channel
79
+ * is recreated and no migration is triggered.
80
+ */
81
+ async apply(
82
+ tones: readonly ToneDeclaration[],
83
+ groups: readonly ChannelGroup[] = [],
84
+ ): Promise<ChannelApplyResult> {
85
+ const now = deps.now();
86
+
87
+ const created: string[] = [];
88
+ const migrated: { toneId: string; from: number; to: number }[] = [];
89
+ const retired: string[] = [];
90
+ const reHomed: string[] = [];
91
+ const cancelled: string[] = [];
92
+ const bypassUnavailable: string[] = [];
93
+ const unchanged: string[] = [];
94
+
95
+ if (groups.length > 0) {
96
+ await deps.engine.declareGroups(groups);
97
+ }
98
+
99
+ const capabilities = await deps.engine.getCapabilities();
100
+
101
+ for (const tone of tones) {
102
+ const previous = ledgerFor(deps.state, tone.id);
103
+ const resolved = resolveTone(tone);
104
+
105
+ if (previous !== undefined && previous.appliedVersion === tone.version) {
106
+ unchanged.push(tone.id);
107
+ continue;
108
+ }
109
+
110
+ if (tone.bypassDoNotDisturb === true && !capabilities.supportsDoNotDisturbBypass) {
111
+ // Reported honestly rather than silently dropped — a consumer that
112
+ // asked for a bypass deserves to know the platform refused.
113
+ bypassUnavailable.push(tone.id);
114
+ emit(
115
+ DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
116
+ `Tone "${tone.id}" requested a Do Not Disturb bypass, which this platform does not permit.`,
117
+ { toneId: tone.id },
118
+ );
119
+ }
120
+
121
+ const didCreate = await deps.engine.declareTone(resolved);
122
+ if (didCreate) {
123
+ created.push(resolved.android.channelId);
124
+ }
125
+
126
+ if (previous === undefined) {
127
+ recordApplied(deps.state, tone, now);
128
+ continue;
129
+ }
130
+
131
+ // A version moved, so this is a migration rather than a first
132
+ // declaration. Re-home before retiring: the reverse order would leave
133
+ // live notifications attached to a channel that no longer exists.
134
+ emit(
135
+ DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
136
+ `Tone "${tone.id}" moved from version ${previous.appliedVersion} to ${tone.version}. Android channels are immutable, so a new channel was created and the old one is being retired.`,
137
+ { toneId: tone.id, from: previous.appliedVersion, to: tone.version },
138
+ );
139
+
140
+ const pending = await deps.engine.getPending();
141
+ const affected = pending.filter(entry => entry.tone === tone.id);
142
+
143
+ for (const entry of affected) {
144
+ await deps.engine.schedule({
145
+ request: {
146
+ id: entry.id,
147
+ content: entry.content,
148
+ tone: entry.tone,
149
+ moment: { at: entry.resolvedAt, anchoring: entry.anchoring },
150
+ routing: entry.routing,
151
+ actions: entry.actions,
152
+ ownerTag: entry.ownerTag,
153
+ groupTag: entry.groupTag,
154
+ },
155
+ resolvedAt: entry.resolvedAt,
156
+ tone: resolved,
157
+ // Re-homing changes the channel, not the anchoring — preserve it.
158
+ anchoringGeneration: entry.anchoringGeneration,
159
+ approximate: entry.mayBeDelayed,
160
+ seriesId: entry.seriesId,
161
+ pushCreated: entry.pushCreated,
162
+ });
163
+ reHomed.push(entry.id);
164
+ }
165
+
166
+ const didRetire = await deps.engine.retireTone(previous.channelId);
167
+ if (didRetire) {
168
+ retired.push(previous.channelId);
169
+ }
170
+
171
+ migrated.push({ toneId: tone.id, from: previous.appliedVersion, to: tone.version });
172
+ recordApplied(deps.state, tone, now, previous.channelId);
173
+ }
174
+
175
+ // Anything left on a retired channel would be an orphan — a notification
176
+ // the user can no longer control from system settings.
177
+ const orphans = await findOrphans(deps, tones);
178
+ if (orphans.length > 0) {
179
+ await deps.engine.cancelMany(orphans);
180
+ cancelled.push(...orphans);
181
+ emit(
182
+ DIAGNOSTIC_CODES.CHANNEL_ORPHANS_FOUND,
183
+ `${orphans.length} notification(s) remained on a retired channel and were cancelled, rather than left uncontrollable from system settings.`,
184
+ { count: orphans.length },
185
+ );
186
+ }
187
+
188
+ return { created, migrated, retired, reHomed, cancelled, bypassUnavailable, unchanged };
189
+ },
190
+
191
+ /** Which version of each tone this device actually holds. */
192
+ appliedVersions(): readonly { readonly toneId: string; readonly appliedVersion: number }[] {
193
+ return deps.state
194
+ .read()
195
+ .migrationLedger.filter(entry => !entry.retired)
196
+ .map(entry => ({ toneId: entry.toneId, appliedVersion: entry.appliedVersion }));
197
+ },
198
+ };
199
+ }
200
+
201
+ /** Notifications whose tone is no longer declared at all. */
202
+ async function findOrphans(
203
+ deps: ChannelDependencies,
204
+ tones: readonly ToneDeclaration[],
205
+ ): Promise<readonly string[]> {
206
+ const declared = new Set(tones.map(tone => tone.id));
207
+ const pending = await deps.engine.getPending();
208
+ return pending.filter(entry => !declared.has(entry.tone)).map(entry => entry.id);
209
+ }
210
+
211
+ export type Channels = ReturnType<typeof createChannels>;