@chipmobilesdk/rn-notification 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +100 -10
  3. package/package.json +6 -1
  4. package/skills/sdk-rn-notification/SKILL.md +6 -5
  5. package/src/advanced.ts +35 -0
  6. package/src/channel/migrate.ts +176 -158
  7. package/src/channel/tone-map.ts +2 -10
  8. package/src/contract/errors.ts +7 -0
  9. package/src/contract/hash.ts +26 -0
  10. package/src/engine/adapter.ts +21 -1
  11. package/src/engine/notify-kit/events.ts +155 -0
  12. package/src/engine/notify-kit/module.ts +148 -0
  13. package/src/engine/notify-kit/platform/android.ts +172 -0
  14. package/src/engine/notify-kit/platform/index.ts +55 -0
  15. package/src/engine/notify-kit/platform/ios.ts +54 -0
  16. package/src/engine/notify-kit/platform/types.ts +40 -0
  17. package/src/engine/notify-kit-engine.ts +96 -477
  18. package/src/index.ts +9 -0
  19. package/src/notifications.ts +195 -0
  20. package/src/permission/index.ts +65 -4
  21. package/src/push/fcm-provider.ts +14 -4
  22. package/src/push/gate.ts +27 -5
  23. package/src/reconcile/diff.ts +38 -27
  24. package/src/reconcile/index.ts +13 -4
  25. package/src/recurrence/index.ts +2 -1
  26. package/src/repeat/expiry.ts +2 -6
  27. package/src/repeat/index.ts +45 -1
  28. package/src/repeat/marker.ts +1 -22
  29. package/src/repeat/place.ts +74 -35
  30. package/src/schedule/index.ts +10 -4
  31. package/src/schedule/partition.ts +0 -19
  32. package/src/schedule/validate-identifier.ts +8 -6
  33. package/src/schedule/writer.ts +13 -4
  34. package/src/state/caps.ts +28 -3
  35. package/src/state/permission-history.ts +25 -0
  36. package/src/state/record.ts +110 -20
  37. package/src/testing/fake-engine.ts +17 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # @chipmobilesdk/rn-notification
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `createNotifications(config, deps?)` assembles the whole package in one call. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
8
+
9
+ - It returns the scheduler, reconciler, recurrence, permissions, channels, interactions
10
+ and snapshot, wired over one engine, one state store and the one redacting
11
+ diagnostic emitter, plus `onInteraction`, `runMaintenance`, `canScheduleExactly` and
12
+ `dispose`. The engine, store, clock and time zone default to the real ones and can
13
+ be injected.
14
+ - `canScheduleExactly` follows the exact-alarm permission the package last read,
15
+ instead of each app passing its own callback (often `() => true`).
16
+ - Automatic maintenance has its standard steps registered: re-anchoring after a
17
+ time-zone change and the permission re-read, plus recurrence window refresh when you
18
+ pass `recurrenceRules`.
19
+ - The individual factories stay exported from the root, and are also available from
20
+ the new `@chipmobilesdk/rn-notification/advanced` entrypoint for apps that wire the
21
+ package themselves.
22
+ - Push: `getAvailability()` no longer fetches a token (which registered the device
23
+ with FCM, and APNs on iOS, just to answer the question). The new
24
+ `gate.handleBackground(raw)` runs a background payload through the same
25
+ normalization and persisted deduplication as a foreground one. Several
26
+ `onTokenChange` subscriptions no longer leak each other, and `dispose()` releases
27
+ them all.
28
+ - The README starts from `createNotifications`; its quick start is checked against a
29
+ type-checked example in the tests.
30
+
31
+ - Fewer reads across the native bridge, storage written per collection, and one
32
+ pending budget for the whole app. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
33
+
34
+ - A reconcile, a group or owner cancel, and a tone apply each read the pending set
35
+ (or the channel list) once per pass instead of once per operation. Reconciling 100
36
+ changes now makes at most two full reads of the trigger list; it used to make one
37
+ or more per change. Scheduling one notification asks for identifiers only where the
38
+ engine can list them (`pendingIds`, optional on the engine port).
39
+ - Persisted state is stored under one key per collection. Recording a push
40
+ deduplication entry writes the dedup ledger alone, where every update used to
41
+ serialize the whole record. A record written by an earlier version is migrated on
42
+ first load.
43
+ - The series and migration ledgers no longer evict live entries at their cap. Only
44
+ exhausted series and retired channels are dropped; if live entries alone exceed the
45
+ cap they are all kept and the new `STATE_CAP_EXCEEDED` diagnostic says so. Evicting a
46
+ live entry reset a series' count and made a migrated tone look new.
47
+ - Reconciliation budgets a domain against the platform ceiling minus what other
48
+ domains, series and direct schedules already hold. Two domains could previously
49
+ overflow iOS's 64 pending notifications together while each reported no truncation.
50
+
51
+ - Three fixes: what a tone change cancels, background interactions, and permanent denial. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
52
+
53
+ - `channels.apply()` cancels a pending notification only when it is on a tone the
54
+ package applied earlier and the configuration no longer declares. It used to cancel
55
+ everything whose tone was not in the list passed to that call, which included
56
+ notifications scheduled by other code through the same engine.
57
+ - Taps, actions and dismissals that arrive while the app is in the background are
58
+ delivered. `registerBackgroundNotificationHandler` takes an optional `onInteraction`
59
+ callback and an optional `store`: a running app's `onInteraction` handlers get the
60
+ event with `appState: 'background'`; otherwise the callback does; with no callback, or
61
+ one that throws, the event is kept in the store and delivered on the next start.
62
+ Before, background actions such as "Taken" or "Snooze" never reached the app.
63
+ - `permanentlyDenied` is now reported: after one refused display request on iOS, two on
64
+ Android 13+, and `DISPLAY_PERMISSION_PERMANENT` is emitted. The count resets once the
65
+ permission is seen granted. After `requestExactAlarmPermission()` the package re-reads
66
+ the capability when the user returns from system settings and notifies
67
+ `onPermissionChange`.
68
+
69
+ ### Patch Changes
70
+
71
+ - The notify-kit engine is split by platform. Android and iOS behaviour live in separate
72
+ strategy files chosen once when the engine is created, instead of about fifteen
73
+ `Platform.OS` branches in one 790-line file. `createNotifyKitEngine` accepts an optional
74
+ `platform` for testing one platform's behaviour. No behaviour change. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
75
+
3
76
  ## 0.5.0
4
77
 
5
78
  ### Minor Changes
package/README.md CHANGED
@@ -17,6 +17,55 @@ Every notification feature re-solves the same problems, and most of them are dis
17
17
  - Display permission and exact-alarm permission are **different things**, asked separately, and a refusal must not break the user's actual work.
18
18
  - Android channels are **immutable once created** — changing one is a migration, not an assignment.
19
19
 
20
+ ## Quick start
21
+
22
+ `createNotifications` assembles the whole package: scheduler, reconciler, recurrence,
23
+ permissions, channels, interactions, a diagnostic snapshot, and automatic maintenance.
24
+ It applies your tones, loads persisted state, and schedules exactly only while the
25
+ exact-alarm permission allows it.
26
+
27
+ <!-- quick-start -->
28
+ ```ts
29
+ import { createNotifications } from '@chipmobilesdk/rn-notification';
30
+
31
+ export async function setUpNotifications(navigate: (routing: unknown) => void) {
32
+ const notifications = await createNotifications({
33
+ tones: [
34
+ {
35
+ id: 'reminder',
36
+ name: 'Reminders',
37
+ urgency: 'high',
38
+ sound: { enabled: true },
39
+ vibration: true,
40
+ badge: true,
41
+ version: 1,
42
+ },
43
+ ],
44
+ });
45
+
46
+ await notifications.permissions.requestDisplayPermission();
47
+
48
+ await notifications.scheduler.schedule({
49
+ id: 'task:42:due', // derived from your data, never random
50
+ content: { title: 'Pay the rent', body: 'Due today' },
51
+ tone: 'reminder',
52
+ moment: { at: Date.parse('2026-12-01T08:00:00') },
53
+ routing: { screen: 'task', taskId: '42' },
54
+ });
55
+
56
+ notifications.onInteraction(async event => {
57
+ if (event.kind === 'tap') navigate(event.routing);
58
+ });
59
+
60
+ return notifications;
61
+ }
62
+ ```
63
+ <!-- /quick-start -->
64
+
65
+ The individual factories (`createScheduler`, `createReconciler`, `initialize`, the
66
+ maintenance steps, ...) remain available, from the package root and from
67
+ `@chipmobilesdk/rn-notification/advanced`, for an app that needs a different wiring.
68
+
20
69
  ## Installation
21
70
 
22
71
  ```sh
@@ -150,18 +199,25 @@ The package re-anchors wall-clock notifications when it notices the device zone
150
199
  | Applies `config.tones` (and any channel migration) during `initialize()` | `reconcile()` — the desired set |
151
200
  | Runs every step on `runtime.registry` when the app returns to the foreground — with the trigger `timeZoneChange` when the device zone moved meanwhile | |
152
201
 
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()`:
202
+ `createNotifications` registers the standard steps for you: re-anchoring after a
203
+ time-zone change, re-reading permissions, and — when you pass your recurrence rules —
204
+ refreshing their windows. Steps that need inputs only your app has are added on
205
+ `notifications.runtime.registry`:
154
206
 
155
207
  ```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();
208
+ const notifications = await createNotifications(config, {
209
+ recurrenceRules: () => myRules, // the rules your app registered; the package never stores them
210
+ });
211
+ notifications.runtime.registry.register(
212
+ createVerifyStep({ engine: notifications.runtime.engine, expectedIds: () => myExpectedIds }),
213
+ );
214
+ // On teardown (tests, a sign-out that rebuilds everything):
215
+ notifications.dispose();
163
216
  ```
164
217
 
218
+ Wiring it yourself with `initialize()` and the step factories (from `./advanced`) still
219
+ works; then registering the steps is yours.
220
+
165
221
  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
222
 
167
223
  **Reconciliation never runs on its own.** Only you know what your data implies; a package that guessed would cancel notifications it does not understand.
@@ -298,6 +354,13 @@ await permissions.openSettings('display'); // per-permission
298
354
 
299
355
  Six states, never collapsed: `notAsked`, `granted`, `denied`, `permanentlyDenied`, `provisional`, `notRequired`.
300
356
 
357
+ `permanentlyDenied` means the system will not show its prompt again, so offer Settings
358
+ (`shouldOfferSettings(state)`). The platform itself only ever says `denied`; the package
359
+ counts refusals — one on iOS, two on Android 13+ — and forgets them once the permission
360
+ is seen granted. The exact-alarm capability is granted on a system settings screen, so
361
+ `requestExactAlarmPermission()` answers with the state before the user chose; the real
362
+ answer is re-read when they return to the app and reaches `onPermissionChange`.
363
+
301
364
  **Ask after intent, never on first launch.** A prompt at the wrong moment is a permanent denial.
302
365
 
303
366
  **A refusal never fails your operation.** The user's work completes; the lost capability is reported separately.
@@ -310,7 +373,26 @@ notifications.onInteraction(async event => {
310
373
  });
311
374
  ```
312
375
 
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.
376
+ One handler, identical shape in foreground, background, and cold start (`event.appState` says which).
377
+
378
+ **Background actions need the module-scope handler.** The engine has one background
379
+ handler per process, registered in `index.js`. Pass it your store and, optionally, a
380
+ callback:
381
+
382
+ ```ts
383
+ registerBackgroundNotificationHandler({
384
+ engine,
385
+ store: createMmkvStore(), // an interaction nobody handled is kept for the next start
386
+ onInteraction: async event => {
387
+ // Optional: handle "Taken" or "Snooze" right away, inside the background window.
388
+ },
389
+ });
390
+ ```
391
+
392
+ While the app is running, a background tap or action goes to your `onInteraction`
393
+ handlers as usual. In a process started only for the event, it goes to the callback
394
+ above; without one (or if it throws) it is kept in the store and delivered when the app
395
+ next starts. Before 0.6.0 these events were dropped. Events that arrive before your handler registers are **buffered durably** — they survive process death, which is exactly what a cold-start tap is.
314
396
 
315
397
  Text the user typed into a reply action (`inputText`) is the exception: it is free text, so it is **never written to disk**. It is held in memory beside the buffer, so a handler that registers later in the same process still receives it; if the process dies first, the event is delivered without `inputText`.
316
398
 
@@ -342,6 +424,14 @@ Foreground pushes are **never auto-displayed** — presentation is your decision
342
424
 
343
425
  Local schedules take precedence over push for the same identifier. A server that wants to override sends `cancel` then `schedule`.
344
426
 
427
+ ## Channel changes and notifications already scheduled
428
+
429
+ `channels.apply()` cancels a pending notification only when it sits on a tone this
430
+ package applied earlier and the configuration no longer declares. Notifications
431
+ scheduled by other code through the same engine, and tones the package has never
432
+ applied, are left alone (0.6.0; before, anything not in the list passed to `apply()` was
433
+ cancelled).
434
+
345
435
  ## What is persisted
346
436
 
347
437
  Six collections, in app-private, backup-excluded storage:
@@ -412,7 +502,7 @@ The state snapshot (`createSnapshot`) reports the pending set entry by entry, bu
412
502
 
413
503
  45 stable identifiers across validation, reconciliation, permissions, channels, interaction, push, state, and volume. Every one is reachable through the fault injector. See `ALL_DIAGNOSTIC_CODES`.
414
504
 
415
- Some are **reported outcomes, not failures** — the operation succeeded and something you should know happened alongside it: `TRUNCATED_OVER_CEILING`, `EXACT_ALARM_UNAVAILABLE`, `TARGET_MISSING`, `PENDING_SET_DISCREPANCY`, `BURST_THRESHOLD_EXCEEDED`, `PAYLOAD_VERSION_SKEW`, `PAYLOAD_DUPLICATE`, `STATE_CAP_EVICTED`, `STATE_CORRUPT_REBUILT`, `DND_BYPASS_UNAVAILABLE`, `TONE_LABELS_REFRESHED`, `REPEAT_ALERT_UNAVAILABLE`, `REPEAT_STOP_HANDLER_MISSING`. Use `isNonFailure(code)`.
505
+ 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_CAP_EXCEEDED`, `STATE_CORRUPT_REBUILT`, `DND_BYPASS_UNAVAILABLE`, `TONE_LABELS_REFRESHED`, `REPEAT_ALERT_UNAVAILABLE`, `REPEAT_STOP_HANDLER_MISSING`. Use `isNonFailure(code)`.
416
506
 
417
507
  ## License
418
508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-notification",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -11,6 +11,11 @@
11
11
  "react-native": "./src/index.ts",
12
12
  "default": "./src/index.ts"
13
13
  },
14
+ "./advanced": {
15
+ "types": "./src/advanced.ts",
16
+ "react-native": "./src/advanced.ts",
17
+ "default": "./src/advanced.ts"
18
+ },
14
19
  "./push": {
15
20
  "types": "./src/push/index.ts",
16
21
  "react-native": "./src/push/index.ts",
@@ -35,11 +35,12 @@ Three things cause most of the bugs, and none is visible in the type signatures:
35
35
  2. **Wall-clock is the default anchoring mode** — an 08:00 reminder stays 08:00 after the
36
36
  user changes time zone. One case re-anchoring cannot fix: read
37
37
  `### What re-anchoring cannot fix` before promising exact behaviour to anyone.
38
- 3. **Reconciliation is never automatic.** Maintenance steps registered on
39
- `runtime.registry` (window refresh, re-anchoring, verification, permission re-read) run
40
- on every foreground return, but the package never invents a desired set. Data changed
41
- and you did not call `reconcile()` means the device is stale, by design. The steps are
42
- not registered for you — they need your rules and expected ids.
38
+ 3. **Reconciliation is never automatic.** Start from `createNotifications(config, deps)`:
39
+ it registers re-anchoring and the permission re-read, plus window refresh when you pass
40
+ `recurrenceRules`, and runs them on every foreground return. It never invents a desired
41
+ set: data changed and you did not call `reconcile()` means the device is stale, by
42
+ design. Wiring with `initialize()` and the factories from `./advanced` leaves step
43
+ registration to you.
43
44
 
44
45
  Read `## Choosing identifiers`, `## Choosing an anchoring mode`, and
45
46
  `## Maintenance versus reconciliation` before designing anything.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `@chipmobilesdk/rn-notification/advanced`: the individual factories, for an app
3
+ * that wires the package itself instead of using `createNotifications` (NOT-07).
4
+ *
5
+ * The package root still exports them too, for compatibility; new code that needs
6
+ * them should import from here, where they are documented as the advanced path.
7
+ */
8
+ export { initialize } from './initialize';
9
+ export type { NotificationRuntime, RuntimeDependencies } from './initialize';
10
+ export { createScheduler } from './schedule';
11
+ export type { Scheduler, ScheduleDependencies } from './schedule';
12
+ export { createReconciler } from './reconcile';
13
+ export type { Reconciler, ReconcileDependencies } from './reconcile';
14
+ export { createRecurrence } from './recurrence';
15
+ export type { Recurrence, RecurrenceDependencies } from './recurrence';
16
+ export { createChannels } from './channel/migrate';
17
+ export type { ChannelDependencies, Channels } from './channel/migrate';
18
+ export { createToneResolver } from './channel/tone-map';
19
+ export { createInteractions } from './interaction';
20
+ export type { InteractionDependencies, Interactions } from './interaction';
21
+ export { createSnapshot } from './diagnostics/snapshot';
22
+ export type { Snapshot, SnapshotDependencies } from './diagnostics/snapshot';
23
+ export { createBurstDetector } from './diagnostics/burst';
24
+ export { createPermissions } from './permission';
25
+ export type { PermissionDependencies, Permissions } from './permission';
26
+ export { createPermissionRefreshStep } from './maintenance/permission-refresh';
27
+ export { createReAnchorStep } from './maintenance/reanchor';
28
+ export { createVerifyStep } from './maintenance/verify';
29
+ export { createWindowRefreshStep } from './maintenance/window-refresh';
30
+ export { MaintenanceController } from './maintenance/toggle';
31
+ export { MaintenanceRegistry } from './maintenance/cycle';
32
+ export type { MaintenanceContext, MaintenanceStep, MaintenanceTrigger } from './maintenance/cycle';
33
+ export { StateManager } from './state/record';
34
+ export { createMmkvStore } from './state/mmkv-store';
35
+ export { createPlatformTimeZoneReader } from './schedule/anchor';