@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
@@ -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
+ }