@chipmobilesdk/rn-notification 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +108 -12
  3. package/package.json +10 -2
  4. package/skills/sdk-rn-notification/SKILL.md +6 -5
  5. package/src/advanced.ts +35 -0
  6. package/src/channel/migrate.ts +176 -158
  7. package/src/channel/tone-map.ts +2 -10
  8. package/src/contract/errors.ts +21 -6
  9. package/src/contract/hash.ts +26 -0
  10. package/src/diagnostics/events.ts +10 -1
  11. package/src/diagnostics/redact.ts +9 -26
  12. package/src/diagnostics/snapshot.ts +34 -1
  13. package/src/engine/adapter.ts +21 -1
  14. package/src/engine/notify-kit/events.ts +155 -0
  15. package/src/engine/notify-kit/module.ts +148 -0
  16. package/src/engine/notify-kit/platform/android.ts +172 -0
  17. package/src/engine/notify-kit/platform/index.ts +55 -0
  18. package/src/engine/notify-kit/platform/ios.ts +54 -0
  19. package/src/engine/notify-kit/platform/types.ts +40 -0
  20. package/src/engine/notify-kit-engine.ts +96 -468
  21. package/src/index.ts +9 -0
  22. package/src/initialize.ts +10 -3
  23. package/src/notifications.ts +195 -0
  24. package/src/permission/index.ts +65 -4
  25. package/src/push/fcm-provider.ts +28 -11
  26. package/src/push/gate.ts +27 -5
  27. package/src/reconcile/diff.ts +38 -27
  28. package/src/reconcile/index.ts +13 -4
  29. package/src/recurrence/index.ts +2 -1
  30. package/src/repeat/expiry.ts +2 -6
  31. package/src/repeat/index.ts +45 -1
  32. package/src/repeat/marker.ts +1 -22
  33. package/src/repeat/place.ts +74 -35
  34. package/src/schedule/anchor.ts +35 -19
  35. package/src/schedule/index.ts +10 -4
  36. package/src/schedule/partition.ts +0 -19
  37. package/src/schedule/validate-identifier.ts +8 -6
  38. package/src/schedule/writer.ts +13 -4
  39. package/src/state/caps.ts +28 -3
  40. package/src/state/interaction-buffer.ts +30 -2
  41. package/src/state/mmkv-store.ts +54 -37
  42. package/src/state/permission-history.ts +25 -0
  43. package/src/state/record.ts +114 -20
  44. package/src/state/store.ts +3 -0
  45. package/src/testing/fake-engine.ts +17 -1
@@ -74,192 +74,202 @@ export function createChannels(deps: ChannelDependencies) {
74
74
  // has to follow, or it would retire with the old channel.
75
75
  const writer = createScheduleWriter(deps.engine);
76
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);
77
+ const applyTones = async (
78
+ tones: readonly ToneDeclaration[],
79
+ groups: readonly ChannelGroup[],
80
+ ): Promise<ChannelApplyResult> => {
81
+ // Validation completes before the first engine call, so a rejected
82
+ // declaration leaves the device exactly as it was — the same order the
83
+ // scheduler has always used.
84
+ validateToneDeclarations(tones);
92
85
 
93
- const now = deps.now();
86
+ const now = deps.now();
94
87
 
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[] = [];
88
+ const created: string[] = [];
89
+ const migrated: { toneId: string; from: number; to: number }[] = [];
90
+ const labelsRefreshed: string[] = [];
91
+ const retired: string[] = [];
92
+ const reHomed: string[] = [];
93
+ const cancelled: string[] = [];
94
+ const bypassUnavailable: string[] = [];
95
+ const repeatUnavailable: string[] = [];
96
+ const unchanged: string[] = [];
104
97
 
105
- if (groups.length > 0) {
106
- await deps.engine.declareGroups(groups);
107
- }
98
+ if (groups.length > 0) {
99
+ await deps.engine.declareGroups(groups);
100
+ }
101
+
102
+ const capabilities = await deps.engine.getCapabilities();
108
103
 
109
- const capabilities = await deps.engine.getCapabilities();
104
+ for (const tone of tones) {
105
+ const previous = ledgerFor(deps.state, tone.id);
106
+ const resolved = resolveTone(tone);
107
+ const fingerprint = toneLabelFingerprint(tone);
110
108
 
111
- for (const tone of tones) {
112
- const previous = ledgerFor(deps.state, tone.id);
113
- const resolved = resolveTone(tone);
114
- const fingerprint = toneLabelFingerprint(tone);
109
+ // Refusals are reported for every tone that asked, whether or not
110
+ // anything else about it moved. A platform declining an attribute
111
+ // says nothing about whether the channel is new.
112
+ if (tone.bypassDoNotDisturb === true && !capabilities.supportsDoNotDisturbBypass) {
113
+ // Reported honestly rather than silently dropped — a consumer that
114
+ // asked for a bypass deserves to know the platform refused.
115
+ bypassUnavailable.push(tone.id);
116
+ emit(
117
+ DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
118
+ `Tone "${tone.id}" requested a Do Not Disturb bypass, which this platform does not permit.`,
119
+ { toneId: tone.id },
120
+ );
121
+ }
115
122
 
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
+ if (tone.repeatAlert !== undefined) {
124
+ if (!capabilities.supportsRepeatingAlert) {
125
+ repeatUnavailable.push(tone.id);
123
126
  emit(
124
- DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
125
- `Tone "${tone.id}" requested a Do Not Disturb bypass, which this platform does not permit.`,
127
+ DIAGNOSTIC_CODES.REPEAT_ALERT_UNAVAILABLE,
128
+ `Tone "${tone.id}" requested a repeating alert, which this platform does not offer. The notification still delivers and still sounds once.`,
129
+ { toneId: tone.id },
130
+ );
131
+ } else if (!deps.engine.hasBackgroundDeliveryHandler()) {
132
+ // Said out loud at declaration time, because the alternative is a
133
+ // user meeting it as a phone that will not stop ringing.
134
+ emit(
135
+ DIAGNOSTIC_CODES.REPEAT_STOP_HANDLER_MISSING,
136
+ `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.`,
126
137
  { toneId: tone.id },
127
138
  );
128
139
  }
140
+ }
129
141
 
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
- }
142
+ const versionMoved = previous !== undefined && previous.appliedVersion !== tone.version;
177
143
 
178
- recordApplied(deps.state, tone, now, undefined, fingerprint);
144
+ if (previous !== undefined && !versionMoved) {
145
+ // The version is the same, so nothing the platform freezes has
146
+ // changed. Only the labels can still be stale — and correcting
147
+ // those must cost the user nothing, so the platform is touched
148
+ // only when the fingerprint actually moved.
149
+ if (previous.labelFingerprint === fingerprint) {
150
+ unchanged.push(tone.id);
179
151
  continue;
180
152
  }
181
153
 
182
154
  const outcome = await deps.engine.declareTone(resolved);
183
155
  if (outcome === 'created') {
156
+ // The channel was gone — deleted by the user, or lost with the
157
+ // app's data — so this is a first declaration rather than a
158
+ // refresh, whatever our ledger believed.
184
159
  created.push(resolved.android.channelId);
160
+ } else if (outcome === 'labelsRefreshed') {
161
+ labelsRefreshed.push(tone.id);
162
+ emit(
163
+ DIAGNOSTIC_CODES.TONE_LABELS_REFRESHED,
164
+ `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.`,
165
+ { toneId: tone.id },
166
+ );
167
+ } else {
168
+ unchanged.push(tone.id);
185
169
  }
186
170
 
187
- if (previous === undefined) {
188
- recordApplied(deps.state, tone, now, undefined, fingerprint);
189
- continue;
190
- }
171
+ recordApplied(deps.state, tone, now, undefined, fingerprint);
172
+ continue;
173
+ }
191
174
 
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
- );
175
+ const outcome = await deps.engine.declareTone(resolved);
176
+ if (outcome === 'created') {
177
+ created.push(resolved.android.channelId);
178
+ }
200
179
 
201
- const pending = await deps.engine.getPending();
202
- const affected = pending.filter(entry => entry.tone === tone.id);
180
+ if (previous === undefined) {
181
+ recordApplied(deps.state, tone, now, undefined, fingerprint);
182
+ continue;
183
+ }
203
184
 
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
- }
185
+ // A version moved, so this is a migration rather than a first
186
+ // declaration. Re-home before retiring: the reverse order would leave
187
+ // live notifications attached to a channel that no longer exists.
188
+ emit(
189
+ DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
190
+ `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.`,
191
+ { toneId: tone.id, from: previous.appliedVersion, to: tone.version },
192
+ );
226
193
 
227
- const didRetire = await deps.engine.retireTone(previous.channelId);
228
- if (didRetire) {
229
- retired.push(previous.channelId);
230
- }
194
+ const pending = await deps.engine.getPending();
195
+ const affected = pending.filter(entry => entry.tone === tone.id);
231
196
 
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);
197
+ for (const entry of affected) {
198
+ await writer.write({
199
+ request: {
200
+ id: entry.id,
201
+ content: entry.content,
202
+ tone: entry.tone,
203
+ moment: { at: entry.resolvedAt, anchoring: entry.anchoring },
204
+ routing: entry.routing,
205
+ actions: entry.actions,
206
+ ownerTag: entry.ownerTag,
207
+ groupTag: entry.groupTag,
208
+ },
209
+ resolvedAt: entry.resolvedAt,
210
+ tone: resolved,
211
+ // Re-homing changes the channel, not the anchoring — preserve it.
212
+ anchoringGeneration: entry.anchoringGeneration,
213
+ approximate: entry.mayBeDelayed,
214
+ seriesId: entry.seriesId,
215
+ pushCreated: entry.pushCreated,
216
+ });
217
+ reHomed.push(entry.id);
237
218
  }
238
219
 
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
- );
220
+ const didRetire = await deps.engine.retireTone(previous.channelId);
221
+ if (didRetire) {
222
+ retired.push(previous.channelId);
250
223
  }
251
224
 
252
- return {
253
- created,
254
- migrated,
255
- labelsRefreshed,
256
- retired,
257
- reHomed,
258
- cancelled,
259
- bypassUnavailable,
260
- repeatUnavailable,
261
- unchanged,
262
- };
225
+ // A version move outranks a label change: the new channel simply
226
+ // carries the new labels, and reporting it as a refresh would hide a
227
+ // migration the consumer needs to know about.
228
+ migrated.push({ toneId: tone.id, from: previous.appliedVersion, to: tone.version });
229
+ recordApplied(deps.state, tone, now, previous.channelId, fingerprint);
230
+ }
231
+
232
+ // Anything left on a retired channel would be an orphan — a notification
233
+ // the user can no longer control from system settings.
234
+ const orphans = await findOrphans(deps, tones);
235
+ if (orphans.length > 0) {
236
+ await deps.engine.cancelMany(orphans);
237
+ cancelled.push(...orphans);
238
+ emit(
239
+ DIAGNOSTIC_CODES.CHANNEL_ORPHANS_FOUND,
240
+ `${orphans.length} notification(s) remained on a retired channel and were cancelled, rather than left uncontrollable from system settings.`,
241
+ { count: orphans.length },
242
+ );
243
+ }
244
+
245
+ return {
246
+ created,
247
+ migrated,
248
+ labelsRefreshed,
249
+ retired,
250
+ reHomed,
251
+ cancelled,
252
+ bypassUnavailable,
253
+ repeatUnavailable,
254
+ unchanged,
255
+ };
256
+ };
257
+
258
+ return {
259
+ /**
260
+ * Applies a tone configuration, migrating any tone whose version moved.
261
+ *
262
+ * Applying an unchanged configuration performs no work at all: no channel
263
+ * is recreated and no migration is triggered.
264
+ */
265
+ apply(
266
+ tones: readonly ToneDeclaration[],
267
+ groups: readonly ChannelGroup[] = [],
268
+ ): Promise<ChannelApplyResult> {
269
+ // The declared channels are read once for the whole apply rather than once
270
+ // per tone, when the engine can hold them (NOT-08).
271
+ const run = () => applyTones(tones, groups);
272
+ return deps.engine.withChannelSnapshot ? deps.engine.withChannelSnapshot(run) : run();
263
273
  },
264
274
 
265
275
  /** Which version of each tone this device actually holds. */
@@ -278,8 +288,16 @@ async function findOrphans(
278
288
  tones: readonly ToneDeclaration[],
279
289
  ): Promise<readonly string[]> {
280
290
  const declared = new Set(tones.map(tone => tone.id));
291
+ // Only tones this package once applied can leave orphans (NOT-05). An entry
292
+ // with no tone metadata was scheduled by other code through the same engine,
293
+ // and a tone the ledger has never seen is not ours to judge either: cancelling
294
+ // on "not in this call's list" silently deleted other libraries' notifications,
295
+ // and a feature module's own when it applied only its subset of tones.
296
+ const onceApplied = new Set(deps.state.read().migrationLedger.map(entry => entry.toneId));
281
297
  const pending = await deps.engine.getPending();
282
- return pending.filter(entry => !declared.has(entry.tone)).map(entry => entry.id);
298
+ return pending
299
+ .filter(entry => entry.tone !== '' && onceApplied.has(entry.tone) && !declared.has(entry.tone))
300
+ .map(entry => entry.id);
283
301
  }
284
302
 
285
303
  export type Channels = ReturnType<typeof createChannels>;
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { ResolvedTone, ToneDeclaration, ToneUrgency } from '../contract/channel';
16
+ import { fnv1a, hex8 } from '../contract/hash';
16
17
 
17
18
  type AndroidImportance = ResolvedTone['android']['importance'];
18
19
  type IosInterruption = ResolvedTone['ios']['interruptionLevel'];
@@ -109,16 +110,7 @@ export function toneLabelFingerprint(tone: ToneDeclaration): string {
109
110
  // of the two would never get its correction.
110
111
  const source = `${tone.name}\u0000${tone.description ?? ''}`;
111
112
 
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 */
113
+ return hex8(fnv1a(source));
122
114
  }
123
115
 
124
116
  /** Builds a lookup over a declared configuration. */
@@ -11,6 +11,10 @@
11
11
  * See `specs/009-rn-notification-package/contracts/errors.md`.
12
12
  */
13
13
 
14
+ import { SdkError } from '@chipmobilesdk/rn-core';
15
+
16
+ import { redactContext } from '../diagnostics/redact';
17
+
14
18
  export const DIAGNOSTIC_CODES = {
15
19
  // --- Validation: raised before any platform call -------------------------
16
20
  INVALID_ID_EMPTY: 'INVALID_ID_EMPTY',
@@ -66,7 +70,15 @@ export const DIAGNOSTIC_CODES = {
66
70
  // --- Persisted state -----------------------------------------------------
67
71
  STATE_CORRUPT_REBUILT: 'STATE_CORRUPT_REBUILT',
68
72
  STATE_WRITE_FAILED: 'STATE_WRITE_FAILED',
73
+ /** The durable store could not open; state lasts for this session only. */
74
+ STATE_STORE_DEGRADED: 'STATE_STORE_DEGRADED',
69
75
  STATE_CAP_EVICTED: 'STATE_CAP_EVICTED',
76
+ /**
77
+ * A ledger holds more live entries than its cap (0.6.0). They are all kept:
78
+ * dropping a live series or channel record would lose a migration or reset a
79
+ * series count. Reported so the cap can be raised or the app's use reviewed.
80
+ */
81
+ STATE_CAP_EXCEEDED: 'STATE_CAP_EXCEEDED',
70
82
 
71
83
  // --- Volume --------------------------------------------------------------
72
84
  BURST_THRESHOLD_EXCEEDED: 'BURST_THRESHOLD_EXCEEDED',
@@ -91,16 +103,18 @@ export const ALL_DIAGNOSTIC_CODES: readonly DiagnosticCode[] = Object.freeze(
91
103
  * a rule, the *shape* of the violation is reported — a length, a character
92
104
  * position — rather than the value itself.
93
105
  */
94
- export class NotificationError extends Error {
95
- readonly code: DiagnosticCode;
106
+ export class NotificationError extends SdkError<DiagnosticCode> {
96
107
  readonly context?: Readonly<Record<string, unknown>>;
97
108
 
98
109
  constructor(code: DiagnosticCode, message: string, context?: Readonly<Record<string, unknown>>) {
99
- super(message);
110
+ super('rn-notification', code, { message });
100
111
  this.name = 'NotificationError';
101
- this.code = code;
102
- this.context = context;
103
- Object.setPrototypeOf(this, NotificationError.prototype);
112
+ // Redacted on the way in: an error object reaches crash reporters, which
113
+ // never run with verbose diagnostics.
114
+ const redacted = redactContext(context, false);
115
+ if (redacted !== undefined) {
116
+ this.context = redacted;
117
+ }
104
118
  }
105
119
  }
106
120
 
@@ -121,6 +135,7 @@ export const NON_FAILURE_CODES: readonly DiagnosticCode[] = Object.freeze([
121
135
  DIAGNOSTIC_CODES.PAYLOAD_VERSION_SKEW,
122
136
  DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
123
137
  DIAGNOSTIC_CODES.STATE_CAP_EVICTED,
138
+ DIAGNOSTIC_CODES.STATE_CAP_EXCEEDED,
124
139
  DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT,
125
140
  DIAGNOSTIC_CODES.BACKGROUND_RESTRICTION_UNKNOWN,
126
141
  DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * FNV-1a, 32-bit: the package's one hash (NOT-19). Deterministic,
3
+ * dependency-free, and stable across releases.
4
+ *
5
+ * Bitwise operators are the algorithm, not a micro-optimisation: arithmetic
6
+ * equivalents would not wrap to 32 bits. Stability matters: a marker id or a
7
+ * label fingerprint that changed between releases would orphan every marker
8
+ * already scheduled, or refresh every channel label once more.
9
+ */
10
+ export const FNV_OFFSET_BASIS = 0x811c9dc5;
11
+
12
+ /* eslint-disable no-bitwise */
13
+ export function fnv1a(value: string, basis: number = FNV_OFFSET_BASIS): number {
14
+ let hash = basis;
15
+ for (let index = 0; index < value.length; index += 1) {
16
+ hash ^= value.codePointAt(index) ?? 0;
17
+ hash = Math.imul(hash, 0x01000193) >>> 0;
18
+ }
19
+ return hash >>> 0;
20
+ }
21
+ /* eslint-enable no-bitwise */
22
+
23
+ /** Eight lowercase hex digits. */
24
+ export function hex8(value: number): string {
25
+ return value.toString(16).padStart(8, '0');
26
+ }
@@ -7,6 +7,8 @@
7
7
  * or stays silent.
8
8
  */
9
9
 
10
+ import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
11
+
10
12
  import type { DiagnosticEvent, DiagnosticSeverity, EventSink } from '../contract/diagnostics';
11
13
  import type { DiagnosticCode } from '../contract/errors';
12
14
  import { isNonFailure } from '../contract/errors';
@@ -18,6 +20,8 @@ export interface EmitterOptions {
18
20
  readonly now?: () => number;
19
21
  }
20
22
 
23
+ const channel = createDiagnosticChannel<DiagnosticCode>('rn-notification');
24
+
21
25
  export class DiagnosticEmitter {
22
26
  private sink?: EventSink;
23
27
  private verbose: boolean;
@@ -47,12 +51,17 @@ export class DiagnosticEmitter {
47
51
  context?: Readonly<Record<string, unknown>>,
48
52
  severity?: DiagnosticSeverity,
49
53
  ): void {
54
+ const resolvedSeverity = severity ?? (isNonFailure(code) ? 'info' : 'error');
55
+ // The app's SDK sinks (`installSdkSink`, CORE-08) never get content, even
56
+ // with verbose diagnostics on: verbose is for this package's own sink.
57
+ channel.emit(code, resolvedSeverity, redactContext(context, false));
58
+
50
59
  // No sink means no work at all — not a buffer that grows until one appears.
51
60
  if (this.sink === undefined) return;
52
61
 
53
62
  const event: DiagnosticEvent = {
54
63
  code,
55
- severity: severity ?? (isNonFailure(code) ? 'info' : 'error'),
64
+ severity: resolvedSeverity,
56
65
  message,
57
66
  at: this.now(),
58
67
  context: redactContext(context, this.verbose),
@@ -10,6 +10,8 @@
10
10
  * — which is what actually helps someone debug a validation failure anyway.
11
11
  */
12
12
 
13
+ import { createRedactor } from '@chipmobilesdk/rn-core';
14
+
13
15
  /** Keys whose values are never emitted at any verbosity below verbose. */
14
16
  const REDACTED_KEYS = new Set([
15
17
  'title',
@@ -26,17 +28,12 @@ const REDACTED_KEYS = new Set([
26
28
  'data',
27
29
  ]);
28
30
 
29
- const REDACTED = '[redacted]';
30
-
31
- function describe(value: unknown): string {
32
- if (value === null) return '[null]';
33
- if (value === undefined) return '[undefined]';
34
- if (typeof value === 'string') return `[string:${value.length}]`;
35
- if (typeof value === 'number' || typeof value === 'boolean') return REDACTED;
36
- if (Array.isArray(value)) return `[array:${value.length}]`;
37
- if (typeof value === 'object') return `[object:${Object.keys(value).length}]`;
38
- return REDACTED;
39
- }
31
+ /**
32
+ * rn-core's redactor: the keys above are replaced by their shape (a length, a
33
+ * type), and every other string is pattern-redacted, so a token or an e-mail
34
+ * address under an innocent key such as `reason` does not get through either.
35
+ */
36
+ const redactor = createRedactor({ shapeKeys: REDACTED_KEYS });
40
37
 
41
38
  /**
42
39
  * Strips content values from a diagnostic context.
@@ -49,21 +46,7 @@ export function redactContext(
49
46
  context: Readonly<Record<string, unknown>> | undefined,
50
47
  verbose: boolean,
51
48
  ): Readonly<Record<string, unknown>> | undefined {
52
- if (context === undefined) return undefined;
53
- if (verbose) return context;
54
-
55
- const walk = (value: unknown, depth: number): unknown => {
56
- if (depth > 6 || value === null || typeof value !== 'object') return value;
57
- if (Array.isArray(value)) return value.map(item => walk(item, depth + 1));
58
-
59
- const output: Record<string, unknown> = {};
60
- for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
61
- output[key] = REDACTED_KEYS.has(key) ? describe(inner) : walk(inner, depth + 1);
62
- }
63
- return output;
64
- };
65
-
66
- return walk(context, 0) as Readonly<Record<string, unknown>>;
49
+ return verbose ? context : redactor.context(context);
67
50
  }
68
51
 
69
52
  /** True when a key would be redacted. Exported so tests can assert the policy directly. */
@@ -9,6 +9,8 @@
9
9
  * into a bug report.
10
10
  */
11
11
 
12
+ import { describeShape } from '@chipmobilesdk/rn-core';
13
+
12
14
  import type { StateSnapshot } from '../contract/diagnostics';
13
15
  import type { PendingEntry } from '../contract/notification';
14
16
  import type { PermissionReport, RestrictionReport } from '../contract/permission';
@@ -30,6 +32,35 @@ export interface SnapshotDependencies {
30
32
  readonly lastMaintenance: () => MaintenanceResult | undefined;
31
33
  /** Presence only — never the value. */
32
34
  readonly hasPushToken: () => boolean;
35
+ /**
36
+ * Whether verbose diagnostics are on. Off (the default), pending entries carry
37
+ * the shape of their content and routing (`[string:12]`), not the values.
38
+ */
39
+ readonly verbose?: () => boolean;
40
+ }
41
+
42
+ /**
43
+ * A pending entry with content replaced by its shape. A snapshot is the thing
44
+ * most likely to be pasted into a bug report, so it never carries notification
45
+ * text or routing values unless verbose diagnostics is on (NOT-14).
46
+ */
47
+ function withoutContent(entry: PendingEntry): PendingEntry {
48
+ const { content, routing } = entry;
49
+ return {
50
+ ...entry,
51
+ content: {
52
+ title: describeShape(content.title),
53
+ body: describeShape(content.body),
54
+ ...(content.subtitle === undefined ? {} : { subtitle: describeShape(content.subtitle) }),
55
+ },
56
+ ...(routing === undefined
57
+ ? {}
58
+ : {
59
+ routing: Object.fromEntries(
60
+ Object.entries(routing).map(([key, value]) => [key, describeShape(value)]),
61
+ ),
62
+ }),
63
+ };
33
64
  }
34
65
 
35
66
  export function createSnapshot(deps: SnapshotDependencies) {
@@ -52,7 +83,9 @@ export function createSnapshot(deps: SnapshotDependencies) {
52
83
  appliedVersion: tone.appliedVersion,
53
84
  })),
54
85
  pendingCount: pending.length,
55
- pending: pending as readonly PendingEntry[],
86
+ pending: deps.verbose?.()
87
+ ? (pending as readonly PendingEntry[])
88
+ : pending.map(entry => withoutContent(entry as PendingEntry)),
56
89
  lastReconcile: deps.lastReconcile(),
57
90
  lastMaintenance: maintenance,
58
91
  discrepancy,