@chipmobilesdk/rn-notification 0.1.2 → 0.2.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 (69) hide show
  1. package/CHANGELOG.md +80 -42
  2. package/README.md +399 -306
  3. package/package.json +2 -1
  4. package/skills/sdk-rn-notification/SKILL.md +68 -3
  5. package/src/channel/migrate.ts +86 -12
  6. package/src/channel/tone-map.ts +130 -90
  7. package/src/channel/validate-tone.ts +61 -0
  8. package/src/contract/channel.ts +188 -89
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +130 -123
  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 +60 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +123 -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 +64 -64
  21. package/src/engine/adapter.ts +172 -79
  22. package/src/engine/notify-kit-engine.ts +761 -535
  23. package/src/index.ts +24 -0
  24. package/src/initialize.ts +174 -174
  25. package/src/interaction/index.ts +146 -146
  26. package/src/maintenance/cycle.ts +147 -147
  27. package/src/maintenance/permission-refresh.ts +41 -41
  28. package/src/maintenance/reanchor.ts +95 -95
  29. package/src/maintenance/toggle.ts +60 -60
  30. package/src/maintenance/verify.ts +83 -83
  31. package/src/maintenance/window-refresh.ts +61 -61
  32. package/src/permission/index.ts +183 -183
  33. package/src/permission/state.ts +77 -77
  34. package/src/push/envelope.ts +165 -165
  35. package/src/push/fcm-provider.ts +146 -146
  36. package/src/push/gate.ts +190 -190
  37. package/src/push/instructions.ts +109 -109
  38. package/src/push/provider-adapter.ts +54 -54
  39. package/src/push/signout.ts +65 -65
  40. package/src/reconcile/chunk.ts +53 -53
  41. package/src/reconcile/diff.ts +145 -145
  42. package/src/reconcile/duplicates.ts +73 -73
  43. package/src/reconcile/index.ts +14 -1
  44. package/src/reconcile/truncate.ts +56 -56
  45. package/src/recurrence/clock-guard.ts +71 -71
  46. package/src/recurrence/expand.ts +292 -292
  47. package/src/repeat/expiry.ts +55 -0
  48. package/src/repeat/index.ts +54 -0
  49. package/src/repeat/marker.ts +72 -0
  50. package/src/repeat/place.ts +73 -0
  51. package/src/schedule/anchor.ts +267 -267
  52. package/src/schedule/index.ts +184 -173
  53. package/src/schedule/partition.ts +85 -85
  54. package/src/schedule/resolve-moment.ts +80 -80
  55. package/src/schedule/validate-content.ts +111 -111
  56. package/src/schedule/validate-identifier.ts +126 -114
  57. package/src/state/caps.ts +71 -71
  58. package/src/state/interaction-buffer.ts +73 -73
  59. package/src/state/mmkv-store.ts +71 -71
  60. package/src/state/permission-history.ts +41 -41
  61. package/src/state/record.ts +258 -246
  62. package/src/state/reset.ts +41 -41
  63. package/src/state/series-ledger.ts +84 -84
  64. package/src/state/store.ts +40 -40
  65. package/src/testing/conformance/index.ts +248 -194
  66. package/src/testing/fake-engine.ts +469 -284
  67. package/src/testing/fake-provider.ts +129 -129
  68. package/src/testing/faults.ts +72 -72
  69. package/src/testing/measure.ts +336 -333
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Repeat expiry — bounding an alert that repeats until it is handled.
3
+ *
4
+ * The one piece of this package a consumer has to wire up themselves. The
5
+ * engine registers its background task at module scope, outside the React tree,
6
+ * and no library can perform that registration on an app's behalf.
7
+ *
8
+ * An app that declares no repeating tone needs none of this.
9
+ */
10
+
11
+ import type { NotificationEngineAdapter } from '../engine/adapter';
12
+ import { createRepeatExpiryHandler } from './expiry';
13
+
14
+ export { createRepeatExpiryHandler } from './expiry';
15
+ export type { RepeatExpiryHandler, RepeatExpiryDependencies } from './expiry';
16
+ export { REPEAT_STOP_PREFIX, isRepeatStopId, markerIdFor, repeatStopFor } from './marker';
17
+
18
+ export interface BackgroundHandlerRegistration {
19
+ readonly engine: NotificationEngineAdapter;
20
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
21
+ }
22
+
23
+ /**
24
+ * Registers the handler that ends a repeating alert while the app is dead.
25
+ *
26
+ * Call this at module scope in `index.js`, beside the app registration — not
27
+ * inside a component, and not inside an effect. By the time a component mounts,
28
+ * the process that needed this handler has already been asked to run it.
29
+ *
30
+ * Without it a repeating alert still starts and still stops when the user
31
+ * handles it, but nothing ends it on a device left face-down on a table. That
32
+ * is the whole reason the feature exists, so `apply()` reports its absence
33
+ * rather than letting anyone find out the hard way.
34
+ */
35
+ export function registerBackgroundNotificationHandler(
36
+ registration: BackgroundHandlerRegistration,
37
+ ): void {
38
+ const handler = createRepeatExpiryHandler(registration);
39
+
40
+ registration.engine.registerBackgroundDeliveryHandler(event => handler.handle(event));
41
+
42
+ // Both paths, not just the background one. The engine routes a delivery to
43
+ // the foreground listener while the app is alive and to the headless task
44
+ // only when it is not — so registering only the latter leaves the alert
45
+ // sounding for anyone holding the phone when the window runs out, which is
46
+ // the one case they would certainly notice.
47
+ registration.engine.subscribeToDeliveries(event => {
48
+ // Deliberately not awaited: the engine's foreground listener is
49
+ // synchronous, and a rejection here must not take the subscription down
50
+ // with it — a handler that stopped listening would leave every later
51
+ // alarm unbounded.
52
+ handler.handle(event).catch(() => {});
53
+ });
54
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stop-marker identity.
3
+ *
4
+ * A repeating alert has to end without the app being alive, and no platform
5
+ * offers "stop the sound at time T but keep the notification". So the package
6
+ * schedules its own notification at T and reacts to its delivery. That
7
+ * notification is a *marker*: it exists to be delivered and immediately
8
+ * removed, never to be read.
9
+ *
10
+ * The marker's identifier is derived from the target's by hashing rather than by
11
+ * concatenation. Concatenation would overflow the package's 128-character
12
+ * identifier limit for any target already near it, and would make the marker's
13
+ * identity a function of a value the consumer controls — which is how a
14
+ * consumer identifier ends up colliding with an internal one.
15
+ */
16
+
17
+ import type { RepeatStop } from '../engine/adapter';
18
+
19
+ /**
20
+ * Reserved identifier namespace.
21
+ *
22
+ * Rejected as a consumer identifier prefix. A consumer id colliding with a
23
+ * marker would not merely confuse a listing: the expiry handler cancels
24
+ * markers, so the collision would delete a real notification.
25
+ */
26
+ export const REPEAT_STOP_PREFIX = 'cms.stop:';
27
+
28
+ /**
29
+ * FNV-1a, 32-bit. Deterministic, dependency-free, and stable across releases.
30
+ *
31
+ * Bitwise operators are the algorithm, not a micro-optimisation: arithmetic
32
+ * equivalents would not wrap to 32 bits, and a marker id that changed between
33
+ * releases would orphan every marker already scheduled on a device.
34
+ */
35
+ /* eslint-disable no-bitwise */
36
+ function fnv1a(value: string, basis: number): number {
37
+ let hash = basis;
38
+ for (let index = 0; index < value.length; index += 1) {
39
+ hash ^= value.codePointAt(index) ?? 0;
40
+ hash = Math.imul(hash, 0x01000193) >>> 0;
41
+ }
42
+ return hash >>> 0;
43
+ }
44
+ /* eslint-enable no-bitwise */
45
+
46
+ function hex8(value: number): string {
47
+ return value.toString(16).padStart(8, '0');
48
+ }
49
+
50
+ /**
51
+ * The marker identifier for a target notification.
52
+ *
53
+ * Two independent hashes rather than one: a single 32-bit hash collides often
54
+ * enough to matter once a device holds a handful of alarms, and a collision here
55
+ * means one alarm's marker silences the other alarm and never silences its own.
56
+ */
57
+ export function markerIdFor(targetId: string): string {
58
+ return `${REPEAT_STOP_PREFIX}${hex8(fnv1a(targetId, 0x811c9dc5))}${hex8(fnv1a(targetId, 0x9e3779b9))}`;
59
+ }
60
+
61
+ export function isRepeatStopId(id: string): boolean {
62
+ return id.startsWith(REPEAT_STOP_PREFIX);
63
+ }
64
+
65
+ /** Describes the stop for a target whose alert must end at a known instant. */
66
+ export function repeatStopFor(targetId: string, deliveredAt: number, forMs: number): RepeatStop {
67
+ return {
68
+ markerId: markerIdFor(targetId),
69
+ targetId,
70
+ dueAt: deliveredAt + forMs,
71
+ };
72
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Keeping a notification's stop marker in step with the notification.
3
+ *
4
+ * Shared by the scheduler and the reconciler rather than written twice. They are
5
+ * two doors into the same act — reconciliation is how most consumers schedule —
6
+ * and a bound that only existed on one path would be a bound that mostly did not
7
+ * exist.
8
+ */
9
+
10
+ import type { NotificationEngineAdapter } from '../engine/adapter';
11
+ import type { ResolvedTone } from '../contract/channel';
12
+ import { repeatStopFor } from './marker';
13
+
14
+ export interface StopPlacement {
15
+ /**
16
+ * Places the marker for a repeating notification, replacing any it already
17
+ * had.
18
+ *
19
+ * Called on every schedule, replacements included: re-scheduling under an
20
+ * identifier that already had one must leave exactly one entry and exactly
21
+ * one marker, and the old marker's instant is no longer the right one.
22
+ *
23
+ * Nothing is placed when the tone does not repeat or the platform will not
24
+ * honour repetition, so a non-repeating notification costs no extra platform
25
+ * capacity at all.
26
+ */
27
+ sync(targetId: string, tone: ResolvedTone, deliversAt: number): Promise<void>;
28
+ /** Drops any marker still guarding an identifier. */
29
+ release(targetId: string): Promise<void>;
30
+ }
31
+
32
+ export function createStopPlacement(engine: NotificationEngineAdapter): StopPlacement {
33
+ const release = async (targetId: string): Promise<void> => {
34
+ const outstanding = await engine.listRepeatStops();
35
+ const held = outstanding.find(stop => stop.targetId === targetId);
36
+ if (held !== undefined) {
37
+ await engine.cancel(held.markerId);
38
+ }
39
+ };
40
+
41
+ return {
42
+ release,
43
+
44
+ async sync(targetId, tone, deliversAt) {
45
+ await release(targetId);
46
+
47
+ const forMs = tone.android.repeatForMs;
48
+ if (!tone.android.loopSound || forMs === undefined) return;
49
+
50
+ const capabilities = await engine.getCapabilities();
51
+ if (!capabilities.supportsRepeatingAlert) return;
52
+
53
+ const stop = repeatStopFor(targetId, deliversAt, forMs);
54
+ await engine.schedule({
55
+ request: {
56
+ id: stop.markerId,
57
+ // Deliberately empty. A marker is delivered and removed within the
58
+ // same handler invocation; anything readable here would be text a
59
+ // user might glimpse and be confused by.
60
+ content: { title: '', body: '' },
61
+ tone: tone.toneId,
62
+ // Absolute: the bound is a duration from a known instant, not a
63
+ // wall-clock time that should follow the user across time zones.
64
+ moment: { at: stop.dueAt, anchoring: 'absolute' },
65
+ },
66
+ resolvedAt: stop.dueAt,
67
+ tone,
68
+ approximate: false,
69
+ repeatStop: stop,
70
+ });
71
+ },
72
+ };
73
+ }