@chipmobilesdk/rn-notification 0.2.0 → 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 +16 -0
- package/package.json +1 -1
- package/skills/sdk-rn-notification/SKILL.md +1 -1
- package/src/channel/validate-tone.ts +61 -61
- package/src/repeat/expiry.ts +55 -55
- package/src/repeat/index.ts +54 -54
- package/src/repeat/marker.ts +72 -72
- package/src/repeat/place.ts +73 -73
- package/src/state/mmkv-store.ts +9 -5
- package/src/testing/measure.ts +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to `@chipmobilesdk/rn-notification` are documented here.
|
|
|
4
4
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.2.1] — 2026-10-12
|
|
8
|
+
|
|
9
|
+
Patch release. No consumer change required.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **The state store never reached the device's storage.** It opened MMKV with
|
|
14
|
+
`new MMKV()`, the react-native-mmkv **v3** constructor. In v4 — the only major the peer
|
|
15
|
+
range (`>=4.0.0`) allows — `MMKV` is a type, not a value, so the call threw and the
|
|
16
|
+
package fell back to its memory store on every device. Deduplication and cold-start
|
|
17
|
+
buffering were therefore session-scoped — the documented degradation for a missing
|
|
18
|
+
native module, applied where the module was present. The store now uses v4's
|
|
19
|
+
`createMMKV` and `remove`.
|
|
20
|
+
- The repository's test double exported a v3 `MMKV` class, so no test could see it. The
|
|
21
|
+
double now mirrors v4, and `mmkv-store.test.ts` fails against the old code.
|
|
22
|
+
|
|
7
23
|
## [0.2.0]
|
|
8
24
|
|
|
9
25
|
Additive minor, delivering on the reserved **additional tone attributes** commitment below. **No consumer source change, no configuration change, and no tone version bump is required.**
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ name: sdk-rn-notification
|
|
|
3
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.2.
|
|
6
|
+
packageVersion: "0.2.1"
|
|
7
7
|
user-invocable: true
|
|
8
8
|
disable-model-invocation: false
|
|
9
9
|
---
|
|
@@ -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
|
+
}
|
package/src/repeat/expiry.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Repeat expiry.
|
|
3
|
-
*
|
|
4
|
-
* When a stop marker is delivered, the alert it guards has run its declared
|
|
5
|
-
* course. This handler ends the sound and leaves the notification exactly where
|
|
6
|
-
* it is, then removes the marker.
|
|
7
|
-
*
|
|
8
|
-
* Nothing here imports the engine. It talks to the port, which is what lets the
|
|
9
|
-
* whole mechanism be proven on a virtual clock with no device.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { DeliveryEvent, NotificationEngineAdapter } from '../engine/adapter';
|
|
13
|
-
|
|
14
|
-
export interface RepeatExpiryDependencies {
|
|
15
|
-
readonly engine: NotificationEngineAdapter;
|
|
16
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface RepeatExpiryHandler {
|
|
20
|
-
/** Reacts to one delivery. Ignores anything that is not a stop marker. */
|
|
21
|
-
handle(event: DeliveryEvent): Promise<void>;
|
|
22
|
-
/** Removes the marker guarding a target, when the target is cancelled or replaced. */
|
|
23
|
-
release(targetId: string): Promise<void>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createRepeatExpiryHandler(
|
|
27
|
-
deps: RepeatExpiryDependencies,
|
|
28
|
-
): RepeatExpiryHandler {
|
|
29
|
-
return {
|
|
30
|
-
async handle(event: DeliveryEvent): Promise<void> {
|
|
31
|
-
const stop = event.repeatStop;
|
|
32
|
-
if (stop === undefined) return;
|
|
33
|
-
|
|
34
|
-
// Order matters. Silence first, remove the marker second: if the
|
|
35
|
-
// process dies between the two, a marker left behind is harmless
|
|
36
|
-
// noise, whereas a device left sounding is the failure this exists to
|
|
37
|
-
// prevent.
|
|
38
|
-
//
|
|
39
|
-
// `silenceDelivered` returning false is the ordinary case, not an
|
|
40
|
-
// error — it means the user already handled the notification. Checking
|
|
41
|
-
// the tray rather than racing to cancel the marker on interaction is
|
|
42
|
-
// what makes early handling correct by construction: even if the marker
|
|
43
|
-
// fires, there is nothing left to act on.
|
|
44
|
-
await deps.engine.silenceDelivered(stop.targetId);
|
|
45
|
-
await deps.engine.cancel(stop.markerId);
|
|
46
|
-
},
|
|
47
|
-
|
|
48
|
-
async release(targetId: string): Promise<void> {
|
|
49
|
-
const outstanding = await deps.engine.listRepeatStops();
|
|
50
|
-
const marker = outstanding.find(stop => stop.targetId === targetId);
|
|
51
|
-
if (marker === undefined) return;
|
|
52
|
-
await deps.engine.cancel(marker.markerId);
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Repeat expiry.
|
|
3
|
+
*
|
|
4
|
+
* When a stop marker is delivered, the alert it guards has run its declared
|
|
5
|
+
* course. This handler ends the sound and leaves the notification exactly where
|
|
6
|
+
* it is, then removes the marker.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here imports the engine. It talks to the port, which is what lets the
|
|
9
|
+
* whole mechanism be proven on a virtual clock with no device.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { DeliveryEvent, NotificationEngineAdapter } from '../engine/adapter';
|
|
13
|
+
|
|
14
|
+
export interface RepeatExpiryDependencies {
|
|
15
|
+
readonly engine: NotificationEngineAdapter;
|
|
16
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RepeatExpiryHandler {
|
|
20
|
+
/** Reacts to one delivery. Ignores anything that is not a stop marker. */
|
|
21
|
+
handle(event: DeliveryEvent): Promise<void>;
|
|
22
|
+
/** Removes the marker guarding a target, when the target is cancelled or replaced. */
|
|
23
|
+
release(targetId: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createRepeatExpiryHandler(
|
|
27
|
+
deps: RepeatExpiryDependencies,
|
|
28
|
+
): RepeatExpiryHandler {
|
|
29
|
+
return {
|
|
30
|
+
async handle(event: DeliveryEvent): Promise<void> {
|
|
31
|
+
const stop = event.repeatStop;
|
|
32
|
+
if (stop === undefined) return;
|
|
33
|
+
|
|
34
|
+
// Order matters. Silence first, remove the marker second: if the
|
|
35
|
+
// process dies between the two, a marker left behind is harmless
|
|
36
|
+
// noise, whereas a device left sounding is the failure this exists to
|
|
37
|
+
// prevent.
|
|
38
|
+
//
|
|
39
|
+
// `silenceDelivered` returning false is the ordinary case, not an
|
|
40
|
+
// error — it means the user already handled the notification. Checking
|
|
41
|
+
// the tray rather than racing to cancel the marker on interaction is
|
|
42
|
+
// what makes early handling correct by construction: even if the marker
|
|
43
|
+
// fires, there is nothing left to act on.
|
|
44
|
+
await deps.engine.silenceDelivered(stop.targetId);
|
|
45
|
+
await deps.engine.cancel(stop.markerId);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async release(targetId: string): Promise<void> {
|
|
49
|
+
const outstanding = await deps.engine.listRepeatStops();
|
|
50
|
+
const marker = outstanding.find(stop => stop.targetId === targetId);
|
|
51
|
+
if (marker === undefined) return;
|
|
52
|
+
await deps.engine.cancel(marker.markerId);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/repeat/index.ts
CHANGED
|
@@ -1,54 +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
|
-
}
|
|
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
|
+
}
|
package/src/repeat/marker.ts
CHANGED
|
@@ -1,72 +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
|
-
}
|
|
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
|
+
}
|
package/src/repeat/place.ts
CHANGED
|
@@ -1,73 +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
|
-
}
|
|
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
|
+
}
|
package/src/state/mmkv-store.ts
CHANGED
|
@@ -22,10 +22,11 @@ import { createMemoryStore } from './store';
|
|
|
22
22
|
|
|
23
23
|
const INSTANCE_ID = 'chipmobilesdk.notification';
|
|
24
24
|
|
|
25
|
+
/** The slice of a react-native-mmkv v4 instance this store uses (v4 names). */
|
|
25
26
|
interface MmkvLike {
|
|
26
27
|
getString(key: string): string | undefined;
|
|
27
28
|
set(key: string, value: string): void;
|
|
28
|
-
|
|
29
|
+
remove(key: string): unknown;
|
|
29
30
|
clearAll(): void;
|
|
30
31
|
getAllKeys(): string[];
|
|
31
32
|
}
|
|
@@ -42,11 +43,14 @@ export function createMmkvStore(): StateStore {
|
|
|
42
43
|
|
|
43
44
|
try {
|
|
44
45
|
// Required lazily so the package loads in environments without the native module.
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
// v4's factory: up to 0.2.0 this called the v3 `new MMKV()`, which v4 — the
|
|
47
|
+
// only major the peer range allows — no longer exports as a value, so every
|
|
48
|
+
// device silently got the session-scoped memory store.
|
|
49
|
+
const { createMMKV } = require('react-native-mmkv') as {
|
|
50
|
+
createMMKV: (config: { id: string; path?: string }) => MmkvLike;
|
|
47
51
|
};
|
|
48
52
|
|
|
49
|
-
instance =
|
|
53
|
+
instance = createMMKV({
|
|
50
54
|
id: INSTANCE_ID,
|
|
51
55
|
// Caches is backup-excluded by platform design; see the note above.
|
|
52
56
|
path: Platform.OS === 'ios' ? 'Library/Caches/chipmobilesdk-notification' : undefined,
|
|
@@ -58,7 +62,7 @@ export function createMmkvStore(): StateStore {
|
|
|
58
62
|
return {
|
|
59
63
|
getString: key => instance.getString(key),
|
|
60
64
|
setString: (key, value) => instance.set(key, value),
|
|
61
|
-
delete: key => instance.
|
|
65
|
+
delete: key => instance.remove(key),
|
|
62
66
|
clearAll: () => instance.clearAll(),
|
|
63
67
|
approximateBytes: () => {
|
|
64
68
|
let total = 0;
|
package/src/testing/measure.ts
CHANGED
|
@@ -76,10 +76,10 @@ const TONE: ResolvedTone = {
|
|
|
76
76
|
importance: 'default',
|
|
77
77
|
vibration: false,
|
|
78
78
|
badge: false,
|
|
79
|
-
bypassDnd: false,
|
|
80
|
-
name: 'Fixture tone',
|
|
81
|
-
loopSound: false,
|
|
82
|
-
alarmCategory: false,
|
|
79
|
+
bypassDnd: false,
|
|
80
|
+
name: 'Fixture tone',
|
|
81
|
+
loopSound: false,
|
|
82
|
+
alarmCategory: false,
|
|
83
83
|
},
|
|
84
84
|
ios: { categoryId: 'perf', interruptionLevel: 'active', badge: false, repeatRefused: false },
|
|
85
85
|
};
|