@chipmobilesdk/rn-notification 0.1.2 → 0.2.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.
- package/CHANGELOG.md +96 -42
- package/README.md +399 -306
- package/package.json +2 -1
- package/skills/sdk-rn-notification/SKILL.md +68 -3
- package/src/channel/migrate.ts +86 -12
- package/src/channel/tone-map.ts +130 -90
- package/src/channel/validate-tone.ts +61 -0
- package/src/contract/channel.ts +188 -89
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +130 -123
- package/src/contract/index.ts +10 -10
- package/src/contract/interaction.ts +62 -62
- package/src/contract/notification.ts +118 -118
- package/src/contract/permission.ts +60 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +123 -123
- package/src/diagnostics/burst.ts +101 -101
- package/src/diagnostics/events.ts +72 -72
- package/src/diagnostics/redact.ts +72 -72
- package/src/diagnostics/snapshot.ts +64 -64
- package/src/engine/adapter.ts +172 -79
- package/src/engine/notify-kit-engine.ts +761 -535
- package/src/index.ts +24 -0
- package/src/initialize.ts +174 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +147 -147
- package/src/maintenance/permission-refresh.ts +41 -41
- package/src/maintenance/reanchor.ts +95 -95
- package/src/maintenance/toggle.ts +60 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +61 -61
- package/src/permission/index.ts +183 -183
- package/src/permission/state.ts +77 -77
- package/src/push/envelope.ts +165 -165
- package/src/push/fcm-provider.ts +146 -146
- package/src/push/gate.ts +190 -190
- package/src/push/instructions.ts +109 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +145 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +14 -1
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +71 -71
- package/src/recurrence/expand.ts +292 -292
- package/src/repeat/expiry.ts +55 -0
- package/src/repeat/index.ts +54 -0
- package/src/repeat/marker.ts +72 -0
- package/src/repeat/place.ts +73 -0
- package/src/schedule/anchor.ts +267 -267
- package/src/schedule/index.ts +184 -173
- package/src/schedule/partition.ts +85 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -114
- package/src/state/caps.ts +71 -71
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +75 -71
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +258 -246
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +84 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +248 -194
- package/src/testing/fake-engine.ts +469 -284
- package/src/testing/fake-provider.ts +129 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/measure.ts +337 -334
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-notification",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"src/permission",
|
|
35
35
|
"src/push",
|
|
36
36
|
"src/recurrence",
|
|
37
|
+
"src/repeat",
|
|
37
38
|
"src/reconcile",
|
|
38
39
|
"src/schedule",
|
|
39
40
|
"src/state",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sdk-rn-notification
|
|
3
|
-
description: "Local and push notifications via @chipmobilesdk/rn-notification: scheduling, identifiers, wall-clock vs absolute anchoring, idempotent reconciliation, recurrence windows, permissions, Android channel migration, tap routing, FCM. Use when scheduling a reminder, handling a notification tap, requesting notification permission, changing a channel, or debugging a notification that did not arrive."
|
|
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.
|
|
6
|
+
packageVersion: "0.2.1"
|
|
7
7
|
user-invocable: true
|
|
8
8
|
disable-model-invocation: false
|
|
9
9
|
---
|
|
@@ -20,6 +20,7 @@ survives a cold start.
|
|
|
20
20
|
- Reacting to a tap, an action button, or a dismissal
|
|
21
21
|
- Requesting notification or exact-alarm permission
|
|
22
22
|
- Declaring or changing a tone/channel, or enabling push
|
|
23
|
+
- Building an alarm-style reminder that repeats until the user handles it
|
|
23
24
|
- Debugging a notification that did not arrive, or arrived twice
|
|
24
25
|
|
|
25
26
|
**Not** for in-app banners or an inbox (this package renders nothing), for storing app
|
|
@@ -64,7 +65,8 @@ See `### Android` and `### Backup exclusion`.
|
|
|
64
65
|
Three entrypoints. The **root** exports one factory per concern (`createScheduler`,
|
|
65
66
|
`createReconciler`, `createRecurrence`, `createPermissions`, `createChannels`,
|
|
66
67
|
`createInteractions`, `createSnapshot`), the maintenance steps and their registry, plus
|
|
67
|
-
`resolveTone`, `resetState`, `clearOwner`,
|
|
68
|
+
`resolveTone`, `registerBackgroundNotificationHandler`, `resetState`, `clearOwner`,
|
|
69
|
+
`isNonFailure`, `REPEAT_ALERT_BOUNDS` and `DIAGNOSTIC_CODES`.
|
|
68
70
|
**`/push`** carries the push gate, FCM provider, instruction handler and sign-out —
|
|
69
71
|
**import it only if you enable push**, because an app that never touches this subpath ships
|
|
70
72
|
no provider code, no push permissions, and no entitlements. **`/testing`** carries the fake
|
|
@@ -82,6 +84,7 @@ rather than guessing.
|
|
|
82
84
|
| Install, peers, enabling push | `## Installation`, `### Enabling push (optional)` |
|
|
83
85
|
| Native setup | `### iOS`, `### Android`, `### Backup exclusion` |
|
|
84
86
|
| Naming notifications | `## Choosing identifiers` |
|
|
87
|
+
| Tones, alarms, labels, version bumps | `## Tones and channels` |
|
|
85
88
|
| Time zones and travel | `## Choosing an anchoring mode` |
|
|
86
89
|
| Automatic vs your responsibility | `## Maintenance versus reconciliation` |
|
|
87
90
|
| Converging the device to your data | `## Reconciliation`, `## Recurrence` |
|
|
@@ -92,6 +95,68 @@ rather than guessing.
|
|
|
92
95
|
| Limits and timing budgets | `## Budgets` |
|
|
93
96
|
| Error codes | `## Diagnostic identifiers` |
|
|
94
97
|
|
|
98
|
+
## Alarm-style tones (0.2.0+)
|
|
99
|
+
|
|
100
|
+
For a reminder that must not be missed — medication, a wake-up, an appointment — declare
|
|
101
|
+
the repeat on the **tone**, not on each notification:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
await channels.apply([{
|
|
105
|
+
id: 'medication',
|
|
106
|
+
name: t('notifications.medication.name'), // localized; users read this in settings
|
|
107
|
+
description: t('notifications.medication.hint'),
|
|
108
|
+
urgency: 'max',
|
|
109
|
+
sound: { name: 'chime' },
|
|
110
|
+
vibration: true,
|
|
111
|
+
bypassDoNotDisturb: true, // an alarm silenced by DND is not an alarm
|
|
112
|
+
repeatAlert: { forMs: 15 * 60 * 1000 }, // required bound, 1–60 minutes
|
|
113
|
+
timeSensitive: true,
|
|
114
|
+
version: 1, // unchanged — these need no bump
|
|
115
|
+
}]);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Four things to get right, none of them visible in the signatures:
|
|
119
|
+
|
|
120
|
+
1. **The duration is mandatory and bounded (1–60 min, `REPEAT_ALERT_BOUNDS`).** There is no
|
|
121
|
+
unbounded form; the type forbids it. Do not reach for a workaround — an alert with no
|
|
122
|
+
bound means a user comes home to a phone still ringing.
|
|
123
|
+
2. **Expiry stops the sound; it never removes the notification.** The user must still be
|
|
124
|
+
able to see what they missed. If you find yourself cancelling at expiry, you have
|
|
125
|
+
inverted the requirement. The window is a **floor**, not a precise stop — the entry
|
|
126
|
+
that ends the alert inherits normal delivery accuracy, so without exact alarms it
|
|
127
|
+
overruns (a declared 2 min measured 2m35s–3m00s). Never promise a caller an exact cut-off.
|
|
128
|
+
3. **Repeating tones need one setup step**, at module scope in `index.js`, never in a
|
|
129
|
+
component or an effect:
|
|
130
|
+
```js
|
|
131
|
+
import { registerBackgroundNotificationHandler } from '@chipmobilesdk/rn-notification';
|
|
132
|
+
registerBackgroundNotificationHandler({ engine });
|
|
133
|
+
```
|
|
134
|
+
Without it nothing ends the alert while the app is dead. `apply()` reports
|
|
135
|
+
`REPEAT_STOP_HANDLER_MISSING`. An app with no repeating tone needs none of this.
|
|
136
|
+
4. **iOS refuses repetition entirely** — no looping API exists, and the alert level that
|
|
137
|
+
would need an Apple-granted entitlement does not loop either. It is reported in
|
|
138
|
+
`result.repeatUnavailable`; the notification still delivers and sounds once. Never
|
|
139
|
+
promise a caller an iOS alarm loops. `timeSensitive` on iOS also needs the consuming
|
|
140
|
+
app's own Time Sensitive Notifications capability, or it degrades to `active`.
|
|
141
|
+
|
|
142
|
+
Read `## Tones and channels` before promising behaviour to anyone: it carries the
|
|
143
|
+
per-platform matrix and six limits that stay true (notification volume rather than alarm
|
|
144
|
+
volume on Android, the shade stopping an insistent sound, vendor battery management).
|
|
145
|
+
|
|
146
|
+
## Changing a tone: when a version bump is required
|
|
147
|
+
|
|
148
|
+
A bump means a **new channel**, and a new channel means the user loses every setting they
|
|
149
|
+
adjusted. Never bump one to fix a label.
|
|
150
|
+
|
|
151
|
+
| Change | Bump `version`? |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `name`, `description` | **No** — updated in place on existing installs |
|
|
154
|
+
| `repeatAlert`, `timeSensitive` | **No** — realized per notification |
|
|
155
|
+
| `urgency`, `sound`, `vibration`, `badge`, `groupId`, `bypassDoNotDisturb` | **Yes** |
|
|
156
|
+
|
|
157
|
+
`apply()` reports a label correction as `labelsRefreshed`, distinct from `created`,
|
|
158
|
+
`migrated`, and `unchanged`.
|
|
159
|
+
|
|
95
160
|
## Debugging "it did not arrive"
|
|
96
161
|
|
|
97
162
|
Capture a state snapshot (`createSnapshot`) before guessing — permissions, applied channel
|
package/src/channel/migrate.ts
CHANGED
|
@@ -20,7 +20,8 @@ import type {
|
|
|
20
20
|
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
21
21
|
import type { StateManager, MigrationEntry } from '../state/record';
|
|
22
22
|
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
23
|
-
import { channelIdFor, resolveTone } from './tone-map';
|
|
23
|
+
import { channelIdFor, resolveTone, toneLabelFingerprint } from './tone-map';
|
|
24
|
+
import { validateToneDeclarations } from './validate-tone';
|
|
24
25
|
|
|
25
26
|
export interface ChannelDependencies {
|
|
26
27
|
readonly engine: NotificationEngineAdapter;
|
|
@@ -37,7 +38,8 @@ function recordApplied(
|
|
|
37
38
|
state: StateManager,
|
|
38
39
|
tone: ToneDeclaration,
|
|
39
40
|
now: number,
|
|
40
|
-
retiredChannelId
|
|
41
|
+
retiredChannelId: string | undefined,
|
|
42
|
+
labelFingerprint: string,
|
|
41
43
|
): void {
|
|
42
44
|
state.update('migrationLedger', current => {
|
|
43
45
|
const withoutTone = current.filter(entry => entry.toneId !== tone.id);
|
|
@@ -63,6 +65,7 @@ function recordApplied(
|
|
|
63
65
|
appliedVersion: tone.version,
|
|
64
66
|
channelId: channelIdFor(tone.id, tone.version),
|
|
65
67
|
retired: false,
|
|
68
|
+
labelFingerprint,
|
|
66
69
|
},
|
|
67
70
|
];
|
|
68
71
|
});
|
|
@@ -82,14 +85,21 @@ export function createChannels(deps: ChannelDependencies) {
|
|
|
82
85
|
tones: readonly ToneDeclaration[],
|
|
83
86
|
groups: readonly ChannelGroup[] = [],
|
|
84
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
|
+
|
|
85
93
|
const now = deps.now();
|
|
86
94
|
|
|
87
95
|
const created: string[] = [];
|
|
88
96
|
const migrated: { toneId: string; from: number; to: number }[] = [];
|
|
97
|
+
const labelsRefreshed: string[] = [];
|
|
89
98
|
const retired: string[] = [];
|
|
90
99
|
const reHomed: string[] = [];
|
|
91
100
|
const cancelled: string[] = [];
|
|
92
101
|
const bypassUnavailable: string[] = [];
|
|
102
|
+
const repeatUnavailable: string[] = [];
|
|
93
103
|
const unchanged: string[] = [];
|
|
94
104
|
|
|
95
105
|
if (groups.length > 0) {
|
|
@@ -101,12 +111,11 @@ export function createChannels(deps: ChannelDependencies) {
|
|
|
101
111
|
for (const tone of tones) {
|
|
102
112
|
const previous = ledgerFor(deps.state, tone.id);
|
|
103
113
|
const resolved = resolveTone(tone);
|
|
114
|
+
const fingerprint = toneLabelFingerprint(tone);
|
|
104
115
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
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.
|
|
110
119
|
if (tone.bypassDoNotDisturb === true && !capabilities.supportsDoNotDisturbBypass) {
|
|
111
120
|
// Reported honestly rather than silently dropped — a consumer that
|
|
112
121
|
// asked for a bypass deserves to know the platform refused.
|
|
@@ -118,13 +127,65 @@ export function createChannels(deps: ChannelDependencies) {
|
|
|
118
127
|
);
|
|
119
128
|
}
|
|
120
129
|
|
|
121
|
-
|
|
122
|
-
|
|
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') {
|
|
123
184
|
created.push(resolved.android.channelId);
|
|
124
185
|
}
|
|
125
186
|
|
|
126
187
|
if (previous === undefined) {
|
|
127
|
-
recordApplied(deps.state, tone, now);
|
|
188
|
+
recordApplied(deps.state, tone, now, undefined, fingerprint);
|
|
128
189
|
continue;
|
|
129
190
|
}
|
|
130
191
|
|
|
@@ -168,8 +229,11 @@ export function createChannels(deps: ChannelDependencies) {
|
|
|
168
229
|
retired.push(previous.channelId);
|
|
169
230
|
}
|
|
170
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.
|
|
171
235
|
migrated.push({ toneId: tone.id, from: previous.appliedVersion, to: tone.version });
|
|
172
|
-
recordApplied(deps.state, tone, now, previous.channelId);
|
|
236
|
+
recordApplied(deps.state, tone, now, previous.channelId, fingerprint);
|
|
173
237
|
}
|
|
174
238
|
|
|
175
239
|
// Anything left on a retired channel would be an orphan — a notification
|
|
@@ -185,7 +249,17 @@ export function createChannels(deps: ChannelDependencies) {
|
|
|
185
249
|
);
|
|
186
250
|
}
|
|
187
251
|
|
|
188
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
created,
|
|
254
|
+
migrated,
|
|
255
|
+
labelsRefreshed,
|
|
256
|
+
retired,
|
|
257
|
+
reHomed,
|
|
258
|
+
cancelled,
|
|
259
|
+
bypassUnavailable,
|
|
260
|
+
repeatUnavailable,
|
|
261
|
+
unchanged,
|
|
262
|
+
};
|
|
189
263
|
},
|
|
190
264
|
|
|
191
265
|
/** Which version of each tone this device actually holds. */
|
package/src/channel/tone-map.ts
CHANGED
|
@@ -1,90 +1,130 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tone-to-platform mapping.
|
|
3
|
-
*
|
|
4
|
-
* A tone is declared once and realized on each platform here, so consumer code
|
|
5
|
-
* contains zero platform branches. The two platforms model urgency differently
|
|
6
|
-
* — Android by channel importance, iOS by interruption level — and reconciling
|
|
7
|
-
* that difference is exactly the work this package exists to absorb.
|
|
8
|
-
*
|
|
9
|
-
* The channel id carries the tone version. Android channels are immutable once
|
|
10
|
-
* created and re-declaration is silently ignored, so a configuration change has
|
|
11
|
-
* to produce a *new* channel or it will never reach a device that already holds
|
|
12
|
-
* the old one.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { ResolvedTone, ToneDeclaration, ToneUrgency } from '../contract/channel';
|
|
16
|
-
|
|
17
|
-
type AndroidImportance = ResolvedTone['android']['importance'];
|
|
18
|
-
type IosInterruption = ResolvedTone['ios']['interruptionLevel'];
|
|
19
|
-
|
|
20
|
-
const ANDROID_IMPORTANCE: Record<ToneUrgency, AndroidImportance> = {
|
|
21
|
-
min: 'min',
|
|
22
|
-
low: 'low',
|
|
23
|
-
default: 'default',
|
|
24
|
-
high: 'high',
|
|
25
|
-
// Android has no level above `high`; max urgency is expressed by the DND
|
|
26
|
-
// bypass rather than by a distinct importance.
|
|
27
|
-
max: 'high',
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const IOS_INTERRUPTION: Record<ToneUrgency, IosInterruption> = {
|
|
31
|
-
min: 'passive',
|
|
32
|
-
low: 'passive',
|
|
33
|
-
default: 'active',
|
|
34
|
-
high: 'active',
|
|
35
|
-
// `timeSensitive` is the highest level available without the Critical Alerts
|
|
36
|
-
// entitlement, which is out of scope for v1.
|
|
37
|
-
max: 'timeSensitive',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/** Channel id for a tone at a given version. Stable, derived, never random. */
|
|
41
|
-
export function channelIdFor(toneId: string, version: number): string {
|
|
42
|
-
return `${toneId}.v${version}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Extracts the tone and version encoded in a channel id, if it is one of ours. */
|
|
46
|
-
export function parseChannelId(
|
|
47
|
-
channelId: string,
|
|
48
|
-
): { readonly toneId: string; readonly version: number } | undefined {
|
|
49
|
-
const separator = channelId.lastIndexOf('.v');
|
|
50
|
-
if (separator === -1) return undefined;
|
|
51
|
-
|
|
52
|
-
const version = Number.parseInt(channelId.slice(separator + 2), 10);
|
|
53
|
-
if (!Number.isInteger(version) || version < 1) return undefined;
|
|
54
|
-
|
|
55
|
-
return { toneId: channelId.slice(0, separator), version };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function resolveTone(tone: ToneDeclaration): ResolvedTone {
|
|
59
|
-
const silent = tone.sound?.enabled === false;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tone-to-platform mapping.
|
|
3
|
+
*
|
|
4
|
+
* A tone is declared once and realized on each platform here, so consumer code
|
|
5
|
+
* contains zero platform branches. The two platforms model urgency differently
|
|
6
|
+
* — Android by channel importance, iOS by interruption level — and reconciling
|
|
7
|
+
* that difference is exactly the work this package exists to absorb.
|
|
8
|
+
*
|
|
9
|
+
* The channel id carries the tone version. Android channels are immutable once
|
|
10
|
+
* created and re-declaration is silently ignored, so a configuration change has
|
|
11
|
+
* to produce a *new* channel or it will never reach a device that already holds
|
|
12
|
+
* the old one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ResolvedTone, ToneDeclaration, ToneUrgency } from '../contract/channel';
|
|
16
|
+
|
|
17
|
+
type AndroidImportance = ResolvedTone['android']['importance'];
|
|
18
|
+
type IosInterruption = ResolvedTone['ios']['interruptionLevel'];
|
|
19
|
+
|
|
20
|
+
const ANDROID_IMPORTANCE: Record<ToneUrgency, AndroidImportance> = {
|
|
21
|
+
min: 'min',
|
|
22
|
+
low: 'low',
|
|
23
|
+
default: 'default',
|
|
24
|
+
high: 'high',
|
|
25
|
+
// Android has no level above `high`; max urgency is expressed by the DND
|
|
26
|
+
// bypass rather than by a distinct importance.
|
|
27
|
+
max: 'high',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const IOS_INTERRUPTION: Record<ToneUrgency, IosInterruption> = {
|
|
31
|
+
min: 'passive',
|
|
32
|
+
low: 'passive',
|
|
33
|
+
default: 'active',
|
|
34
|
+
high: 'active',
|
|
35
|
+
// `timeSensitive` is the highest level available without the Critical Alerts
|
|
36
|
+
// entitlement, which is out of scope for v1.
|
|
37
|
+
max: 'timeSensitive',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Channel id for a tone at a given version. Stable, derived, never random. */
|
|
41
|
+
export function channelIdFor(toneId: string, version: number): string {
|
|
42
|
+
return `${toneId}.v${version}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extracts the tone and version encoded in a channel id, if it is one of ours. */
|
|
46
|
+
export function parseChannelId(
|
|
47
|
+
channelId: string,
|
|
48
|
+
): { readonly toneId: string; readonly version: number } | undefined {
|
|
49
|
+
const separator = channelId.lastIndexOf('.v');
|
|
50
|
+
if (separator === -1) return undefined;
|
|
51
|
+
|
|
52
|
+
const version = Number.parseInt(channelId.slice(separator + 2), 10);
|
|
53
|
+
if (!Number.isInteger(version) || version < 1) return undefined;
|
|
54
|
+
|
|
55
|
+
return { toneId: channelId.slice(0, separator), version };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveTone(tone: ToneDeclaration): ResolvedTone {
|
|
59
|
+
const silent = tone.sound?.enabled === false;
|
|
60
|
+
const repeats = tone.repeatAlert !== undefined;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
toneId: tone.id,
|
|
64
|
+
version: tone.version,
|
|
65
|
+
android: {
|
|
66
|
+
channelId: channelIdFor(tone.id, tone.version),
|
|
67
|
+
// The declared label, not the identifier. Passing the identifier here
|
|
68
|
+
// is what put a technical string on the one screen these exist for.
|
|
69
|
+
name: tone.name,
|
|
70
|
+
description: tone.description,
|
|
71
|
+
importance: ANDROID_IMPORTANCE[tone.urgency],
|
|
72
|
+
sound: silent ? undefined : tone.sound?.name,
|
|
73
|
+
vibration: tone.vibration ?? false,
|
|
74
|
+
badge: tone.badge ?? false,
|
|
75
|
+
bypassDnd: tone.bypassDoNotDisturb ?? false,
|
|
76
|
+
groupId: tone.groupId,
|
|
77
|
+
// Both are per-notification rather than channel configuration, which
|
|
78
|
+
// is precisely why neither obliges a consumer to bump `version`.
|
|
79
|
+
loopSound: repeats,
|
|
80
|
+
alarmCategory: tone.timeSensitive ?? false,
|
|
81
|
+
repeatForMs: tone.repeatAlert?.forMs,
|
|
82
|
+
},
|
|
83
|
+
ios: {
|
|
84
|
+
// iOS categories are not versioned: they carry actions rather than
|
|
85
|
+
// user-adjustable settings, so re-declaring one is harmless.
|
|
86
|
+
categoryId: tone.id,
|
|
87
|
+
interruptionLevel:
|
|
88
|
+
tone.timeSensitive === true ? 'timeSensitive' : IOS_INTERRUPTION[tone.urgency],
|
|
89
|
+
sound: silent ? undefined : tone.sound?.name,
|
|
90
|
+
badge: tone.badge ?? false,
|
|
91
|
+
// iOS exposes no looping-sound API at all. Recorded as data so the
|
|
92
|
+
// refusal can be reported rather than discovered.
|
|
93
|
+
repeatRefused: repeats,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A stable fingerprint of a tone's user-visible labels.
|
|
100
|
+
*
|
|
101
|
+
* Lets an unchanged configuration skip the platform entirely, which is what
|
|
102
|
+
* keeps "re-applying changes nothing" literally true rather than merely
|
|
103
|
+
* approximately true. A hash rather than the labels themselves, so persisted
|
|
104
|
+
* state continues to hold no consumer-supplied text.
|
|
105
|
+
*/
|
|
106
|
+
export function toneLabelFingerprint(tone: ToneDeclaration): string {
|
|
107
|
+
// The separator matters: without it, a name of "ab" with description "c" and
|
|
108
|
+
// a name of "a" with description "bc" would fingerprint identically, and one
|
|
109
|
+
// of the two would never get its correction.
|
|
110
|
+
const source = `${tone.name}\u0000${tone.description ?? ''}`;
|
|
111
|
+
|
|
112
|
+
// FNV-1a. Bitwise operators are the algorithm, not a micro-optimisation:
|
|
113
|
+
// arithmetic equivalents would not wrap to 32 bits and the hash would drift.
|
|
114
|
+
/* eslint-disable no-bitwise */
|
|
115
|
+
let hash = 0x811c9dc5;
|
|
116
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
117
|
+
hash ^= source.codePointAt(index) ?? 0;
|
|
118
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
119
|
+
}
|
|
120
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
121
|
+
/* eslint-enable no-bitwise */
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Builds a lookup over a declared configuration. */
|
|
125
|
+
export function createToneResolver(
|
|
126
|
+
tones: readonly ToneDeclaration[],
|
|
127
|
+
): (toneId: string) => ResolvedTone | undefined {
|
|
128
|
+
const resolved = new Map(tones.map(tone => [tone.id, resolveTone(tone)]));
|
|
129
|
+
return toneId => resolved.get(toneId);
|
|
130
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tone declaration validation.
|
|
3
|
+
*
|
|
4
|
+
* Runs before the first engine call, like every other validation in this
|
|
5
|
+
* package: a rejected declaration must leave the device exactly as it was.
|
|
6
|
+
*
|
|
7
|
+
* Only the new alert attributes are checked here. The rest of a tone has always
|
|
8
|
+
* been validated by the platform's own charset and shape rules, and duplicating
|
|
9
|
+
* those would create two sources of truth for the same constraint.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ToneDeclaration } from '../contract/channel';
|
|
13
|
+
import { REPEAT_ALERT_BOUNDS } from '../contract/channel';
|
|
14
|
+
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Throws when a tone's repeating alert is unusable.
|
|
18
|
+
*
|
|
19
|
+
* The type already forbids a repeat without a duration. What it cannot forbid
|
|
20
|
+
* is a duration of zero, a fractional millisecond, or an hour and a half — and
|
|
21
|
+
* an alert that repeats for an unchecked span is the failure this whole feature
|
|
22
|
+
* exists to prevent.
|
|
23
|
+
*/
|
|
24
|
+
export function validateToneDeclaration(tone: ToneDeclaration): void {
|
|
25
|
+
const repeat = tone.repeatAlert;
|
|
26
|
+
if (repeat === undefined) return;
|
|
27
|
+
|
|
28
|
+
if (!Number.isInteger(repeat.forMs)) {
|
|
29
|
+
throw new NotificationError(
|
|
30
|
+
DIAGNOSTIC_CODES.INVALID_TONE_REPEAT,
|
|
31
|
+
`Tone "${tone.id}" declared a repeating alert whose duration is not a whole number of milliseconds.`,
|
|
32
|
+
{ toneId: tone.id, forMs: repeat.forMs },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (repeat.forMs < REPEAT_ALERT_BOUNDS.minMs || repeat.forMs > REPEAT_ALERT_BOUNDS.maxMs) {
|
|
37
|
+
throw new NotificationError(
|
|
38
|
+
DIAGNOSTIC_CODES.INVALID_TONE_REPEAT,
|
|
39
|
+
`Tone "${tone.id}" declared a repeating alert of ${repeat.forMs}ms, outside the permitted ${REPEAT_ALERT_BOUNDS.minMs}–${REPEAT_ALERT_BOUNDS.maxMs}ms range. A shorter repeat is indistinguishable from a single chime; a longer one outlasts anyone's willingness to respond.`,
|
|
40
|
+
{ toneId: tone.id, forMs: repeat.forMs, ...REPEAT_ALERT_BOUNDS },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (tone.sound?.enabled === false) {
|
|
45
|
+
// Two instructions that cannot both be honoured. Picking one silently
|
|
46
|
+
// would ship whichever the reader did not expect, and the consumer would
|
|
47
|
+
// discover it on a device rather than here.
|
|
48
|
+
throw new NotificationError(
|
|
49
|
+
DIAGNOSTIC_CODES.INVALID_TONE_REPEAT,
|
|
50
|
+
`Tone "${tone.id}" declares both a repeating alert and a silent sound. Repeating silence is not a behaviour; drop one of the two.`,
|
|
51
|
+
{ toneId: tone.id },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validates a whole configuration before any of it is applied. */
|
|
57
|
+
export function validateToneDeclarations(tones: readonly ToneDeclaration[]): void {
|
|
58
|
+
for (const tone of tones) {
|
|
59
|
+
validateToneDeclaration(tone);
|
|
60
|
+
}
|
|
61
|
+
}
|