@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.
- package/CHANGELOG.md +174 -109
- package/README.md +414 -399
- package/package.json +4 -21
- package/skills/sdk-rn-notification/SKILL.md +5 -4
- package/src/channel/migrate.ts +285 -285
- package/src/channel/tone-map.ts +130 -130
- package/src/channel/validate-tone.ts +61 -61
- package/src/contract/channel.ts +192 -188
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +134 -130
- 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 +55 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +129 -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 +67 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -0
- package/src/engine/notify-kit-engine.ts +706 -761
- package/src/index.ts +208 -208
- package/src/initialize.ts +240 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +150 -147
- package/src/maintenance/permission-refresh.ts +39 -41
- package/src/maintenance/reanchor.ts +101 -95
- package/src/maintenance/toggle.ts +66 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +64 -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 +143 -146
- package/src/push/gate.ts +190 -190
- package/src/push/index.ts +33 -29
- package/src/push/instructions.ts +114 -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 +142 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +180 -188
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +73 -71
- package/src/recurrence/expand.ts +326 -292
- package/src/recurrence/index.ts +270 -177
- package/src/repeat/expiry.ts +53 -55
- package/src/repeat/index.ts +54 -54
- package/src/repeat/marker.ts +72 -72
- package/src/repeat/place.ts +73 -73
- package/src/schedule/anchor.ts +278 -267
- package/src/schedule/index.ts +174 -184
- package/src/schedule/partition.ts +88 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -126
- package/src/schedule/writer.ts +55 -0
- package/src/state/caps.ts +71 -71
- package/src/state/dedup-ledger.ts +35 -35
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +75 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +266 -258
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +258 -248
- package/src/testing/fake-engine.ts +462 -469
- package/src/testing/fake-provider.ts +126 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/index.ts +23 -28
- package/src/testing/measure.ts +337 -337
package/src/channel/tone-map.ts
CHANGED
|
@@ -1,130 +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
|
-
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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,61 +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
|
-
}
|
|
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
|
+
}
|