@chipmobilesdk/rn-notification 0.3.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 -109
  2. package/README.md +414 -399
  3. package/package.json +4 -21
  4. package/skills/sdk-rn-notification/SKILL.md +5 -4
  5. package/src/channel/migrate.ts +285 -285
  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 -130
  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 -123
  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 -0
  23. package/src/engine/notify-kit-engine.ts +706 -761
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +240 -174
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -147
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -95
  30. package/src/maintenance/toggle.ts +66 -60
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -61
  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 -188
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -292
  49. package/src/recurrence/index.ts +270 -177
  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 -184
  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 -0
  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 -258
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -84
  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 -469
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-notification",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -23,22 +23,8 @@
23
23
  }
24
24
  },
25
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/repeat",
38
- "src/reconcile",
39
- "src/schedule",
40
- "src/state",
41
- "src/testing",
26
+ "src",
27
+ "!src/**/__tests__",
42
28
  "skills",
43
29
  "README.md",
44
30
  "CHANGELOG.md"
@@ -46,7 +32,7 @@
46
32
  "sideEffects": false,
47
33
  "scripts": {
48
34
  "typecheck": "tsc -p tsconfig.json --noEmit",
49
- "pack:dry-run": "npm pack --dry-run"
35
+ "pack:check": "npm pack --dry-run"
50
36
  },
51
37
  "peerDependencies": {
52
38
  "@react-native-firebase/app": ">=26.0.0",
@@ -65,9 +51,6 @@
65
51
  "optional": true
66
52
  }
67
53
  },
68
- "engines": {
69
- "node": ">=22.11.0"
70
- },
71
54
  "publishConfig": {
72
55
  "access": "public",
73
56
  "registry": "https://registry.npmjs.org/"
@@ -3,7 +3,6 @@ name: sdk-rn-notification
3
3
  description: "Local and push notifications via @chipmobilesdk/rn-notification: scheduling, identifiers, wall-clock vs absolute anchoring, idempotent reconciliation, recurrence windows, permissions, alarm-style repeating tones with a bounded window, tone labels in system settings, Android channel migration, tap routing, FCM. Use when scheduling a reminder or alarm, making a notification repeat until handled, naming a tone users see in settings, handling a notification tap, requesting notification permission, changing a channel, or debugging a notification that did not arrive."
4
4
  metadata:
5
5
  package: "@chipmobilesdk/rn-notification"
6
- packageVersion: "0.3.0"
7
6
  user-invocable: true
8
7
  disable-model-invocation: false
9
8
  ---
@@ -36,9 +35,11 @@ Three things cause most of the bugs, and none is visible in the type signatures:
36
35
  2. **Wall-clock is the default anchoring mode** — an 08:00 reminder stays 08:00 after the
37
36
  user changes time zone. One case re-anchoring cannot fix: read
38
37
  `### What re-anchoring cannot fix` before promising exact behaviour to anyone.
39
- 3. **Reconciliation is never automatic.** The package maintains its own recurrence windows
40
- and re-anchoring, but never invents a desired set. Data changed and you did not call
41
- `reconcile()` means the device is stale, by design.
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.
42
43
 
43
44
  Read `## Choosing identifiers`, `## Choosing an anchoring mode`, and
44
45
  `## Maintenance versus reconciliation` before designing anything.
@@ -1,285 +1,285 @@
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, toneLabelFingerprint } from './tone-map';
24
- import { validateToneDeclarations } from './validate-tone';
25
-
26
- export interface ChannelDependencies {
27
- readonly engine: NotificationEngineAdapter;
28
- readonly state: StateManager;
29
- readonly now: () => number;
30
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
31
- }
32
-
33
- function ledgerFor(state: StateManager, toneId: string): MigrationEntry | undefined {
34
- return state.read().migrationLedger.find(entry => entry.toneId === toneId && !entry.retired);
35
- }
36
-
37
- function recordApplied(
38
- state: StateManager,
39
- tone: ToneDeclaration,
40
- now: number,
41
- retiredChannelId: string | undefined,
42
- labelFingerprint: string,
43
- ): void {
44
- state.update('migrationLedger', current => {
45
- const withoutTone = current.filter(entry => entry.toneId !== tone.id);
46
- const retired =
47
- retiredChannelId === undefined
48
- ? []
49
- : [
50
- {
51
- at: now,
52
- toneId: tone.id,
53
- appliedVersion: 0,
54
- channelId: retiredChannelId,
55
- retired: true,
56
- },
57
- ];
58
-
59
- return [
60
- ...withoutTone,
61
- ...retired,
62
- {
63
- at: now,
64
- toneId: tone.id,
65
- appliedVersion: tone.version,
66
- channelId: channelIdFor(tone.id, tone.version),
67
- retired: false,
68
- labelFingerprint,
69
- },
70
- ];
71
- });
72
- }
73
-
74
- export function createChannels(deps: ChannelDependencies) {
75
- const emit = deps.emit ?? (() => {});
76
-
77
- return {
78
- /**
79
- * Applies a tone configuration, migrating any tone whose version moved.
80
- *
81
- * Applying an unchanged configuration performs no work at all: no channel
82
- * is recreated and no migration is triggered.
83
- */
84
- async apply(
85
- tones: readonly ToneDeclaration[],
86
- groups: readonly ChannelGroup[] = [],
87
- ): Promise<ChannelApplyResult> {
88
- // Validation completes before the first engine call, so a rejected
89
- // declaration leaves the device exactly as it was — the same order the
90
- // scheduler has always used.
91
- validateToneDeclarations(tones);
92
-
93
- const now = deps.now();
94
-
95
- const created: string[] = [];
96
- const migrated: { toneId: string; from: number; to: number }[] = [];
97
- const labelsRefreshed: string[] = [];
98
- const retired: string[] = [];
99
- const reHomed: string[] = [];
100
- const cancelled: string[] = [];
101
- const bypassUnavailable: string[] = [];
102
- const repeatUnavailable: string[] = [];
103
- const unchanged: string[] = [];
104
-
105
- if (groups.length > 0) {
106
- await deps.engine.declareGroups(groups);
107
- }
108
-
109
- const capabilities = await deps.engine.getCapabilities();
110
-
111
- for (const tone of tones) {
112
- const previous = ledgerFor(deps.state, tone.id);
113
- const resolved = resolveTone(tone);
114
- const fingerprint = toneLabelFingerprint(tone);
115
-
116
- // Refusals are reported for every tone that asked, whether or not
117
- // anything else about it moved. A platform declining an attribute
118
- // says nothing about whether the channel is new.
119
- if (tone.bypassDoNotDisturb === true && !capabilities.supportsDoNotDisturbBypass) {
120
- // Reported honestly rather than silently dropped — a consumer that
121
- // asked for a bypass deserves to know the platform refused.
122
- bypassUnavailable.push(tone.id);
123
- emit(
124
- DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
125
- `Tone "${tone.id}" requested a Do Not Disturb bypass, which this platform does not permit.`,
126
- { toneId: tone.id },
127
- );
128
- }
129
-
130
- if (tone.repeatAlert !== undefined) {
131
- if (!capabilities.supportsRepeatingAlert) {
132
- repeatUnavailable.push(tone.id);
133
- emit(
134
- DIAGNOSTIC_CODES.REPEAT_ALERT_UNAVAILABLE,
135
- `Tone "${tone.id}" requested a repeating alert, which this platform does not offer. The notification still delivers and still sounds once.`,
136
- { toneId: tone.id },
137
- );
138
- } else if (!deps.engine.hasBackgroundDeliveryHandler()) {
139
- // Said out loud at declaration time, because the alternative is a
140
- // user meeting it as a phone that will not stop ringing.
141
- emit(
142
- DIAGNOSTIC_CODES.REPEAT_STOP_HANDLER_MISSING,
143
- `Tone "${tone.id}" declares a repeating alert, but no background notification handler is registered. Its alert will not stop on its own while the app is not running. Call registerBackgroundNotificationHandler at module scope in index.js.`,
144
- { toneId: tone.id },
145
- );
146
- }
147
- }
148
-
149
- const versionMoved = previous !== undefined && previous.appliedVersion !== tone.version;
150
-
151
- if (previous !== undefined && !versionMoved) {
152
- // The version is the same, so nothing the platform freezes has
153
- // changed. Only the labels can still be stale — and correcting
154
- // those must cost the user nothing, so the platform is touched
155
- // only when the fingerprint actually moved.
156
- if (previous.labelFingerprint === fingerprint) {
157
- unchanged.push(tone.id);
158
- continue;
159
- }
160
-
161
- const outcome = await deps.engine.declareTone(resolved);
162
- if (outcome === 'created') {
163
- // The channel was gone — deleted by the user, or lost with the
164
- // app's data — so this is a first declaration rather than a
165
- // refresh, whatever our ledger believed.
166
- created.push(resolved.android.channelId);
167
- } else if (outcome === 'labelsRefreshed') {
168
- labelsRefreshed.push(tone.id);
169
- emit(
170
- DIAGNOSTIC_CODES.TONE_LABELS_REFRESHED,
171
- `Tone "${tone.id}" had its name and description updated in place. No channel was recreated, so every setting the user adjusted on it is untouched.`,
172
- { toneId: tone.id },
173
- );
174
- } else {
175
- unchanged.push(tone.id);
176
- }
177
-
178
- recordApplied(deps.state, tone, now, undefined, fingerprint);
179
- continue;
180
- }
181
-
182
- const outcome = await deps.engine.declareTone(resolved);
183
- if (outcome === 'created') {
184
- created.push(resolved.android.channelId);
185
- }
186
-
187
- if (previous === undefined) {
188
- recordApplied(deps.state, tone, now, undefined, fingerprint);
189
- continue;
190
- }
191
-
192
- // A version moved, so this is a migration rather than a first
193
- // declaration. Re-home before retiring: the reverse order would leave
194
- // live notifications attached to a channel that no longer exists.
195
- emit(
196
- DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
197
- `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.`,
198
- { toneId: tone.id, from: previous.appliedVersion, to: tone.version },
199
- );
200
-
201
- const pending = await deps.engine.getPending();
202
- const affected = pending.filter(entry => entry.tone === tone.id);
203
-
204
- for (const entry of affected) {
205
- await deps.engine.schedule({
206
- request: {
207
- id: entry.id,
208
- content: entry.content,
209
- tone: entry.tone,
210
- moment: { at: entry.resolvedAt, anchoring: entry.anchoring },
211
- routing: entry.routing,
212
- actions: entry.actions,
213
- ownerTag: entry.ownerTag,
214
- groupTag: entry.groupTag,
215
- },
216
- resolvedAt: entry.resolvedAt,
217
- tone: resolved,
218
- // Re-homing changes the channel, not the anchoring — preserve it.
219
- anchoringGeneration: entry.anchoringGeneration,
220
- approximate: entry.mayBeDelayed,
221
- seriesId: entry.seriesId,
222
- pushCreated: entry.pushCreated,
223
- });
224
- reHomed.push(entry.id);
225
- }
226
-
227
- const didRetire = await deps.engine.retireTone(previous.channelId);
228
- if (didRetire) {
229
- retired.push(previous.channelId);
230
- }
231
-
232
- // A version move outranks a label change: the new channel simply
233
- // carries the new labels, and reporting it as a refresh would hide a
234
- // migration the consumer needs to know about.
235
- migrated.push({ toneId: tone.id, from: previous.appliedVersion, to: tone.version });
236
- recordApplied(deps.state, tone, now, previous.channelId, fingerprint);
237
- }
238
-
239
- // Anything left on a retired channel would be an orphan — a notification
240
- // the user can no longer control from system settings.
241
- const orphans = await findOrphans(deps, tones);
242
- if (orphans.length > 0) {
243
- await deps.engine.cancelMany(orphans);
244
- cancelled.push(...orphans);
245
- emit(
246
- DIAGNOSTIC_CODES.CHANNEL_ORPHANS_FOUND,
247
- `${orphans.length} notification(s) remained on a retired channel and were cancelled, rather than left uncontrollable from system settings.`,
248
- { count: orphans.length },
249
- );
250
- }
251
-
252
- return {
253
- created,
254
- migrated,
255
- labelsRefreshed,
256
- retired,
257
- reHomed,
258
- cancelled,
259
- bypassUnavailable,
260
- repeatUnavailable,
261
- unchanged,
262
- };
263
- },
264
-
265
- /** Which version of each tone this device actually holds. */
266
- appliedVersions(): readonly { readonly toneId: string; readonly appliedVersion: number }[] {
267
- return deps.state
268
- .read()
269
- .migrationLedger.filter(entry => !entry.retired)
270
- .map(entry => ({ toneId: entry.toneId, appliedVersion: entry.appliedVersion }));
271
- },
272
- };
273
- }
274
-
275
- /** Notifications whose tone is no longer declared at all. */
276
- async function findOrphans(
277
- deps: ChannelDependencies,
278
- tones: readonly ToneDeclaration[],
279
- ): Promise<readonly string[]> {
280
- const declared = new Set(tones.map(tone => tone.id));
281
- const pending = await deps.engine.getPending();
282
- return pending.filter(entry => !declared.has(entry.tone)).map(entry => entry.id);
283
- }
284
-
285
- export type Channels = ReturnType<typeof createChannels>;
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 { ChannelApplyResult, ChannelGroup, ToneDeclaration } from '../contract/channel';
16
+ import type { NotificationEngineAdapter } from '../engine/adapter';
17
+ import type { StateManager, MigrationEntry } from '../state/record';
18
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
19
+ import { channelIdFor, resolveTone, toneLabelFingerprint } from './tone-map';
20
+ import { validateToneDeclarations } from './validate-tone';
21
+ import { createScheduleWriter } from '../schedule/writer';
22
+
23
+ export interface ChannelDependencies {
24
+ readonly engine: NotificationEngineAdapter;
25
+ readonly state: StateManager;
26
+ readonly now: () => number;
27
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
28
+ }
29
+
30
+ function ledgerFor(state: StateManager, toneId: string): MigrationEntry | undefined {
31
+ return state.read().migrationLedger.find(entry => entry.toneId === toneId && !entry.retired);
32
+ }
33
+
34
+ function recordApplied(
35
+ state: StateManager,
36
+ tone: ToneDeclaration,
37
+ now: number,
38
+ retiredChannelId: string | undefined,
39
+ labelFingerprint: string,
40
+ ): void {
41
+ state.update('migrationLedger', current => {
42
+ const withoutTone = current.filter(entry => entry.toneId !== tone.id);
43
+ const retired =
44
+ retiredChannelId === undefined
45
+ ? []
46
+ : [
47
+ {
48
+ at: now,
49
+ toneId: tone.id,
50
+ appliedVersion: 0,
51
+ channelId: retiredChannelId,
52
+ retired: true,
53
+ },
54
+ ];
55
+
56
+ return [
57
+ ...withoutTone,
58
+ ...retired,
59
+ {
60
+ at: now,
61
+ toneId: tone.id,
62
+ appliedVersion: tone.version,
63
+ channelId: channelIdFor(tone.id, tone.version),
64
+ retired: false,
65
+ labelFingerprint,
66
+ },
67
+ ];
68
+ });
69
+ }
70
+
71
+ export function createChannels(deps: ChannelDependencies) {
72
+ const emit = deps.emit ?? (() => {});
73
+ // Re-homing re-writes each notification on the new channel; its stop marker
74
+ // has to follow, or it would retire with the old channel.
75
+ const writer = createScheduleWriter(deps.engine);
76
+
77
+ return {
78
+ /**
79
+ * Applies a tone configuration, migrating any tone whose version moved.
80
+ *
81
+ * Applying an unchanged configuration performs no work at all: no channel
82
+ * is recreated and no migration is triggered.
83
+ */
84
+ async apply(
85
+ tones: readonly ToneDeclaration[],
86
+ groups: readonly ChannelGroup[] = [],
87
+ ): Promise<ChannelApplyResult> {
88
+ // Validation completes before the first engine call, so a rejected
89
+ // declaration leaves the device exactly as it was — the same order the
90
+ // scheduler has always used.
91
+ validateToneDeclarations(tones);
92
+
93
+ const now = deps.now();
94
+
95
+ const created: string[] = [];
96
+ const migrated: { toneId: string; from: number; to: number }[] = [];
97
+ const labelsRefreshed: string[] = [];
98
+ const retired: string[] = [];
99
+ const reHomed: string[] = [];
100
+ const cancelled: string[] = [];
101
+ const bypassUnavailable: string[] = [];
102
+ const repeatUnavailable: string[] = [];
103
+ const unchanged: string[] = [];
104
+
105
+ if (groups.length > 0) {
106
+ await deps.engine.declareGroups(groups);
107
+ }
108
+
109
+ const capabilities = await deps.engine.getCapabilities();
110
+
111
+ for (const tone of tones) {
112
+ const previous = ledgerFor(deps.state, tone.id);
113
+ const resolved = resolveTone(tone);
114
+ const fingerprint = toneLabelFingerprint(tone);
115
+
116
+ // Refusals are reported for every tone that asked, whether or not
117
+ // anything else about it moved. A platform declining an attribute
118
+ // says nothing about whether the channel is new.
119
+ if (tone.bypassDoNotDisturb === true && !capabilities.supportsDoNotDisturbBypass) {
120
+ // Reported honestly rather than silently dropped — a consumer that
121
+ // asked for a bypass deserves to know the platform refused.
122
+ bypassUnavailable.push(tone.id);
123
+ emit(
124
+ DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
125
+ `Tone "${tone.id}" requested a Do Not Disturb bypass, which this platform does not permit.`,
126
+ { toneId: tone.id },
127
+ );
128
+ }
129
+
130
+ if (tone.repeatAlert !== undefined) {
131
+ if (!capabilities.supportsRepeatingAlert) {
132
+ repeatUnavailable.push(tone.id);
133
+ emit(
134
+ DIAGNOSTIC_CODES.REPEAT_ALERT_UNAVAILABLE,
135
+ `Tone "${tone.id}" requested a repeating alert, which this platform does not offer. The notification still delivers and still sounds once.`,
136
+ { toneId: tone.id },
137
+ );
138
+ } else if (!deps.engine.hasBackgroundDeliveryHandler()) {
139
+ // Said out loud at declaration time, because the alternative is a
140
+ // user meeting it as a phone that will not stop ringing.
141
+ emit(
142
+ DIAGNOSTIC_CODES.REPEAT_STOP_HANDLER_MISSING,
143
+ `Tone "${tone.id}" declares a repeating alert, but no background notification handler is registered. Its alert will not stop on its own while the app is not running. Call registerBackgroundNotificationHandler at module scope in index.js.`,
144
+ { toneId: tone.id },
145
+ );
146
+ }
147
+ }
148
+
149
+ const versionMoved = previous !== undefined && previous.appliedVersion !== tone.version;
150
+
151
+ if (previous !== undefined && !versionMoved) {
152
+ // The version is the same, so nothing the platform freezes has
153
+ // changed. Only the labels can still be stale — and correcting
154
+ // those must cost the user nothing, so the platform is touched
155
+ // only when the fingerprint actually moved.
156
+ if (previous.labelFingerprint === fingerprint) {
157
+ unchanged.push(tone.id);
158
+ continue;
159
+ }
160
+
161
+ const outcome = await deps.engine.declareTone(resolved);
162
+ if (outcome === 'created') {
163
+ // The channel was gone — deleted by the user, or lost with the
164
+ // app's data — so this is a first declaration rather than a
165
+ // refresh, whatever our ledger believed.
166
+ created.push(resolved.android.channelId);
167
+ } else if (outcome === 'labelsRefreshed') {
168
+ labelsRefreshed.push(tone.id);
169
+ emit(
170
+ DIAGNOSTIC_CODES.TONE_LABELS_REFRESHED,
171
+ `Tone "${tone.id}" had its name and description updated in place. No channel was recreated, so every setting the user adjusted on it is untouched.`,
172
+ { toneId: tone.id },
173
+ );
174
+ } else {
175
+ unchanged.push(tone.id);
176
+ }
177
+
178
+ recordApplied(deps.state, tone, now, undefined, fingerprint);
179
+ continue;
180
+ }
181
+
182
+ const outcome = await deps.engine.declareTone(resolved);
183
+ if (outcome === 'created') {
184
+ created.push(resolved.android.channelId);
185
+ }
186
+
187
+ if (previous === undefined) {
188
+ recordApplied(deps.state, tone, now, undefined, fingerprint);
189
+ continue;
190
+ }
191
+
192
+ // A version moved, so this is a migration rather than a first
193
+ // declaration. Re-home before retiring: the reverse order would leave
194
+ // live notifications attached to a channel that no longer exists.
195
+ emit(
196
+ DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
197
+ `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.`,
198
+ { toneId: tone.id, from: previous.appliedVersion, to: tone.version },
199
+ );
200
+
201
+ const pending = await deps.engine.getPending();
202
+ const affected = pending.filter(entry => entry.tone === tone.id);
203
+
204
+ for (const entry of affected) {
205
+ await writer.write({
206
+ request: {
207
+ id: entry.id,
208
+ content: entry.content,
209
+ tone: entry.tone,
210
+ moment: { at: entry.resolvedAt, anchoring: entry.anchoring },
211
+ routing: entry.routing,
212
+ actions: entry.actions,
213
+ ownerTag: entry.ownerTag,
214
+ groupTag: entry.groupTag,
215
+ },
216
+ resolvedAt: entry.resolvedAt,
217
+ tone: resolved,
218
+ // Re-homing changes the channel, not the anchoring — preserve it.
219
+ anchoringGeneration: entry.anchoringGeneration,
220
+ approximate: entry.mayBeDelayed,
221
+ seriesId: entry.seriesId,
222
+ pushCreated: entry.pushCreated,
223
+ });
224
+ reHomed.push(entry.id);
225
+ }
226
+
227
+ const didRetire = await deps.engine.retireTone(previous.channelId);
228
+ if (didRetire) {
229
+ retired.push(previous.channelId);
230
+ }
231
+
232
+ // A version move outranks a label change: the new channel simply
233
+ // carries the new labels, and reporting it as a refresh would hide a
234
+ // migration the consumer needs to know about.
235
+ migrated.push({ toneId: tone.id, from: previous.appliedVersion, to: tone.version });
236
+ recordApplied(deps.state, tone, now, previous.channelId, fingerprint);
237
+ }
238
+
239
+ // Anything left on a retired channel would be an orphan — a notification
240
+ // the user can no longer control from system settings.
241
+ const orphans = await findOrphans(deps, tones);
242
+ if (orphans.length > 0) {
243
+ await deps.engine.cancelMany(orphans);
244
+ cancelled.push(...orphans);
245
+ emit(
246
+ DIAGNOSTIC_CODES.CHANNEL_ORPHANS_FOUND,
247
+ `${orphans.length} notification(s) remained on a retired channel and were cancelled, rather than left uncontrollable from system settings.`,
248
+ { count: orphans.length },
249
+ );
250
+ }
251
+
252
+ return {
253
+ created,
254
+ migrated,
255
+ labelsRefreshed,
256
+ retired,
257
+ reHomed,
258
+ cancelled,
259
+ bypassUnavailable,
260
+ repeatUnavailable,
261
+ unchanged,
262
+ };
263
+ },
264
+
265
+ /** Which version of each tone this device actually holds. */
266
+ appliedVersions(): readonly { readonly toneId: string; readonly appliedVersion: number }[] {
267
+ return deps.state
268
+ .read()
269
+ .migrationLedger.filter(entry => !entry.retired)
270
+ .map(entry => ({ toneId: entry.toneId, appliedVersion: entry.appliedVersion }));
271
+ },
272
+ };
273
+ }
274
+
275
+ /** Notifications whose tone is no longer declared at all. */
276
+ async function findOrphans(
277
+ deps: ChannelDependencies,
278
+ tones: readonly ToneDeclaration[],
279
+ ): Promise<readonly string[]> {
280
+ const declared = new Set(tones.map(tone => tone.id));
281
+ const pending = await deps.engine.getPending();
282
+ return pending.filter(entry => !declared.has(entry.tone)).map(entry => entry.id);
283
+ }
284
+
285
+ export type Channels = ReturnType<typeof createChannels>;