@chipmobilesdk/rn-notification 0.1.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.
- package/CHANGELOG.md +42 -0
- package/README.md +306 -0
- package/package.json +73 -0
- package/src/channel/migrate.ts +211 -0
- package/src/channel/tone-map.ts +90 -0
- package/src/contract/channel.ts +89 -0
- package/src/contract/diagnostics.ts +102 -0
- package/src/contract/errors.ts +123 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/interaction.ts +62 -0
- package/src/contract/notification.ts +118 -0
- package/src/contract/permission.ts +60 -0
- package/src/contract/reconcile.ts +88 -0
- package/src/contract/recurrence.ts +123 -0
- package/src/diagnostics/burst.ts +101 -0
- package/src/diagnostics/events.ts +72 -0
- package/src/diagnostics/redact.ts +72 -0
- package/src/diagnostics/snapshot.ts +64 -0
- package/src/engine/adapter.ts +79 -0
- package/src/engine/notify-kit-engine.ts +535 -0
- package/src/index.ts +173 -0
- package/src/initialize.ts +174 -0
- package/src/interaction/index.ts +146 -0
- package/src/maintenance/cycle.ts +147 -0
- package/src/maintenance/permission-refresh.ts +41 -0
- package/src/maintenance/reanchor.ts +95 -0
- package/src/maintenance/toggle.ts +60 -0
- package/src/maintenance/verify.ts +83 -0
- package/src/maintenance/window-refresh.ts +61 -0
- package/src/permission/index.ts +183 -0
- package/src/permission/state.ts +77 -0
- package/src/push/envelope.ts +165 -0
- package/src/push/fcm-provider.ts +146 -0
- package/src/push/gate.ts +190 -0
- package/src/push/index.ts +29 -0
- package/src/push/instructions.ts +109 -0
- package/src/push/provider-adapter.ts +54 -0
- package/src/push/signout.ts +65 -0
- package/src/reconcile/chunk.ts +53 -0
- package/src/reconcile/diff.ts +145 -0
- package/src/reconcile/duplicates.ts +73 -0
- package/src/reconcile/index.ts +175 -0
- package/src/reconcile/truncate.ts +56 -0
- package/src/recurrence/clock-guard.ts +71 -0
- package/src/recurrence/expand.ts +292 -0
- package/src/recurrence/index.ts +177 -0
- package/src/schedule/anchor.ts +267 -0
- package/src/schedule/index.ts +173 -0
- package/src/schedule/partition.ts +85 -0
- package/src/schedule/resolve-moment.ts +80 -0
- package/src/schedule/validate-content.ts +111 -0
- package/src/schedule/validate-identifier.ts +114 -0
- package/src/state/caps.ts +71 -0
- package/src/state/dedup-ledger.ts +39 -0
- package/src/state/interaction-buffer.ts +73 -0
- package/src/state/mmkv-store.ts +71 -0
- package/src/state/permission-history.ts +41 -0
- package/src/state/record.ts +246 -0
- package/src/state/reset.ts +41 -0
- package/src/state/series-ledger.ts +84 -0
- package/src/state/store.ts +40 -0
- package/src/testing/conformance/index.ts +194 -0
- package/src/testing/fake-engine.ts +284 -0
- package/src/testing/fake-provider.ts +129 -0
- package/src/testing/faults.ts +72 -0
- package/src/testing/index.ts +26 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content validation.
|
|
3
|
+
*
|
|
4
|
+
* The package passes content through **unchanged** and never truncates it.
|
|
5
|
+
* Display truncation is the platform's decision and differs between them; a
|
|
6
|
+
* library that pre-truncated would be guessing at a rule it does not own, and
|
|
7
|
+
* would corrupt content that would have displayed fine.
|
|
8
|
+
*
|
|
9
|
+
* What it does refuse is content over the platform's hard payload limit, where
|
|
10
|
+
* the alternative is not a shortened notification but a failed or undefined
|
|
11
|
+
* delivery. That is a real error and is reported as one, before any platform
|
|
12
|
+
* call.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
16
|
+
import type { NotificationContent } from '../contract/notification';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hard payload ceilings.
|
|
20
|
+
*
|
|
21
|
+
* These are *not* display limits. A title longer than a phone shows is fine and
|
|
22
|
+
* is passed through; the README documents where each platform visually
|
|
23
|
+
* truncates so a caller can decide for itself.
|
|
24
|
+
*
|
|
25
|
+
* The total is deliberately lower than the sum of the parts, so it actually
|
|
26
|
+
* binds — a per-field check that can never be exceeded in aggregate would be
|
|
27
|
+
* dead code. It sits under the 4 KB payload budget both platforms enforce,
|
|
28
|
+
* leaving headroom for the routing payload that shares it.
|
|
29
|
+
*/
|
|
30
|
+
export const CONTENT_LIMITS = {
|
|
31
|
+
maxTitleBytes: 512,
|
|
32
|
+
maxBodyBytes: 3072,
|
|
33
|
+
maxSubtitleBytes: 512,
|
|
34
|
+
maxTotalBytes: 3584,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** UTF-8 byte length — the unit the platforms actually bound. */
|
|
38
|
+
export function byteLength(value: string): number {
|
|
39
|
+
let bytes = 0;
|
|
40
|
+
for (const char of value) {
|
|
41
|
+
const code = char.codePointAt(0) ?? 0;
|
|
42
|
+
if (code <= 0x7f) bytes += 1;
|
|
43
|
+
else if (code <= 0x7ff) bytes += 2;
|
|
44
|
+
else if (code <= 0xffff) bytes += 3;
|
|
45
|
+
else bytes += 4;
|
|
46
|
+
}
|
|
47
|
+
return bytes;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireString(value: unknown, field: string): string {
|
|
51
|
+
if (typeof value !== 'string') {
|
|
52
|
+
throw new NotificationError(
|
|
53
|
+
DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
|
|
54
|
+
`Notification content field "${field}" must be a string of resolved display text. The package performs no locale resolution.`,
|
|
55
|
+
{ field, received: typeof value },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validates content and returns it **byte-identical**.
|
|
63
|
+
*
|
|
64
|
+
* The return value is the same object reference where possible, so nothing
|
|
65
|
+
* downstream can mistake this for a transformation step.
|
|
66
|
+
*/
|
|
67
|
+
export function validateContent(content: NotificationContent): NotificationContent {
|
|
68
|
+
const title = requireString(content.title, 'title');
|
|
69
|
+
const body = requireString(content.body, 'body');
|
|
70
|
+
const subtitle =
|
|
71
|
+
content.subtitle === undefined ? undefined : requireString(content.subtitle, 'subtitle');
|
|
72
|
+
|
|
73
|
+
if (title.length === 0 && body.length === 0) {
|
|
74
|
+
throw new NotificationError(
|
|
75
|
+
DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
|
|
76
|
+
'A notification must carry a title or a body; both were empty.',
|
|
77
|
+
{ field: 'title+body' },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const checks: [string, string, number][] = [
|
|
82
|
+
['title', title, CONTENT_LIMITS.maxTitleBytes],
|
|
83
|
+
['body', body, CONTENT_LIMITS.maxBodyBytes],
|
|
84
|
+
];
|
|
85
|
+
if (subtitle !== undefined) {
|
|
86
|
+
checks.push(['subtitle', subtitle, CONTENT_LIMITS.maxSubtitleBytes]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [field, value, limit] of checks) {
|
|
90
|
+
const size = byteLength(value);
|
|
91
|
+
if (size > limit) {
|
|
92
|
+
throw new NotificationError(
|
|
93
|
+
DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
|
|
94
|
+
`Notification "${field}" is ${size} bytes, over the ${limit}-byte platform limit. ` +
|
|
95
|
+
'It was refused rather than truncated, because the package does not decide where a caller\'s content should be cut.',
|
|
96
|
+
{ field, bytes: size, limit },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const total = byteLength(title) + byteLength(body) + (subtitle ? byteLength(subtitle) : 0);
|
|
102
|
+
if (total > CONTENT_LIMITS.maxTotalBytes) {
|
|
103
|
+
throw new NotificationError(
|
|
104
|
+
DIAGNOSTIC_CODES.CONTENT_TOO_LARGE,
|
|
105
|
+
`Notification content totals ${total} bytes, over the ${CONTENT_LIMITS.maxTotalBytes}-byte payload limit.`,
|
|
106
|
+
{ field: 'total', bytes: total, limit: CONTENT_LIMITS.maxTotalBytes },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return content;
|
|
111
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identifier validation.
|
|
3
|
+
*
|
|
4
|
+
* Identity is the caller's contract: only the caller can derive an identifier
|
|
5
|
+
* reproducibly from its own data, so the package never generates one.
|
|
6
|
+
*
|
|
7
|
+
* The charset is composite-friendly because real derived keys are hierarchical
|
|
8
|
+
* — `task:1234:reminder:2`. A charset that forced encoding of natural keys
|
|
9
|
+
* would work against caller-supplied identity being readable and reproducible,
|
|
10
|
+
* which is most of its value.
|
|
11
|
+
*
|
|
12
|
+
* Nothing is normalized, trimmed, case-folded, or hashed. Two identifiers
|
|
13
|
+
* differing in any character are different identifiers, and the value visible
|
|
14
|
+
* at the platform boundary is exactly what the caller supplied — which is what
|
|
15
|
+
* lets a notification be traced back to the data that produced it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DIAGNOSTIC_CODES, NotificationError, type DiagnosticCode } from '../contract/errors';
|
|
19
|
+
|
|
20
|
+
export const MAX_IDENTIFIER_LENGTH = 128;
|
|
21
|
+
|
|
22
|
+
/** `A–Z a–z 0–9 . _ - : /` — printable, unambiguous, and safe at every boundary. */
|
|
23
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:/-]+$/;
|
|
24
|
+
|
|
25
|
+
/** Reports the first offending character so a failure is actionable without echoing the value. */
|
|
26
|
+
function firstInvalidCharacter(value: string): { char: string; index: number } | undefined {
|
|
27
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
28
|
+
const char = value[index];
|
|
29
|
+
if (!/[A-Za-z0-9._:/-]/.test(char)) {
|
|
30
|
+
return { char, index };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type IdentifierKind = 'id' | 'ownerTag' | 'groupTag' | 'seriesId' | 'actionId';
|
|
37
|
+
|
|
38
|
+
const EMPTY_CODE: Record<IdentifierKind, DiagnosticCode> = {
|
|
39
|
+
id: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
|
|
40
|
+
ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
|
|
41
|
+
groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
|
|
42
|
+
seriesId: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
|
|
43
|
+
actionId: DIAGNOSTIC_CODES.INVALID_ID_EMPTY,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const LENGTH_CODE: Record<IdentifierKind, DiagnosticCode> = {
|
|
47
|
+
id: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
|
|
48
|
+
ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
|
|
49
|
+
groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
|
|
50
|
+
seriesId: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
|
|
51
|
+
actionId: DIAGNOSTIC_CODES.INVALID_ID_LENGTH,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const CHARSET_CODE: Record<IdentifierKind, DiagnosticCode> = {
|
|
55
|
+
id: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
|
|
56
|
+
ownerTag: DIAGNOSTIC_CODES.INVALID_OWNER_TAG,
|
|
57
|
+
groupTag: DIAGNOSTIC_CODES.INVALID_GROUP_TAG,
|
|
58
|
+
seriesId: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
|
|
59
|
+
actionId: DIAGNOSTIC_CODES.INVALID_ID_CHARSET,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Throws a typed error naming the violated rule.
|
|
64
|
+
*
|
|
65
|
+
* The offending character and its position are reported; the value itself never
|
|
66
|
+
* is, because an identifier can carry user data a caller derived it from.
|
|
67
|
+
*/
|
|
68
|
+
export function validateIdentifier(value: unknown, kind: IdentifierKind = 'id'): string {
|
|
69
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
70
|
+
throw new NotificationError(
|
|
71
|
+
EMPTY_CODE[kind],
|
|
72
|
+
`A ${kind} must be a non-empty string supplied by the caller. The package never generates one.`,
|
|
73
|
+
{ kind, received: typeof value },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (value.length > MAX_IDENTIFIER_LENGTH) {
|
|
78
|
+
throw new NotificationError(
|
|
79
|
+
LENGTH_CODE[kind],
|
|
80
|
+
`A ${kind} must be at most ${MAX_IDENTIFIER_LENGTH} characters.`,
|
|
81
|
+
{ kind, length: value.length, max: MAX_IDENTIFIER_LENGTH },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!IDENTIFIER_PATTERN.test(value)) {
|
|
86
|
+
const offender = firstInvalidCharacter(value);
|
|
87
|
+
throw new NotificationError(
|
|
88
|
+
CHARSET_CODE[kind],
|
|
89
|
+
`A ${kind} may contain only A-Z, a-z, 0-9, and the separators . _ - : / — whitespace and control characters are not permitted.`,
|
|
90
|
+
{ kind, index: offender?.index, charCode: offender?.char.charCodeAt(0) },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Non-throwing form, for validating a batch without unwinding on the first failure. */
|
|
98
|
+
export function isValidIdentifier(value: unknown): value is string {
|
|
99
|
+
return (
|
|
100
|
+
typeof value === 'string' &&
|
|
101
|
+
value.length > 0 &&
|
|
102
|
+
value.length <= MAX_IDENTIFIER_LENGTH &&
|
|
103
|
+
IDENTIFIER_PATTERN.test(value)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Validates an optional tag, returning undefined when absent rather than failing. */
|
|
108
|
+
export function validateOptionalTag(
|
|
109
|
+
value: string | undefined,
|
|
110
|
+
kind: Extract<IdentifierKind, 'ownerTag' | 'groupTag'>,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
if (value === undefined) return undefined;
|
|
113
|
+
return validateIdentifier(value, kind);
|
|
114
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caps and eviction for the persisted collections.
|
|
3
|
+
*
|
|
4
|
+
* No collection may grow without bound. Eviction is oldest-first and always
|
|
5
|
+
* observable — a silent eviction would turn a bounded guarantee into an
|
|
6
|
+
* unexplained one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Every persisted collection with a size or age bound. */
|
|
10
|
+
export const COLLECTION_CAPS = {
|
|
11
|
+
dedupLedger: { maxEntries: 500, retentionMs: 24 * 60 * 60 * 1000 },
|
|
12
|
+
interactionBuffer: { maxEntries: 50, retentionMs: 7 * 24 * 60 * 60 * 1000 },
|
|
13
|
+
migrationLedger: { maxEntries: 20, retentionMs: Number.POSITIVE_INFINITY },
|
|
14
|
+
seriesLedger: { maxEntries: 32, retentionMs: Number.POSITIVE_INFINITY },
|
|
15
|
+
permissionHistory: { maxEntries: 64, retentionMs: Number.POSITIVE_INFINITY },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type CollectionName = keyof typeof COLLECTION_CAPS;
|
|
19
|
+
|
|
20
|
+
/** Anything stored with a timestamp, which is everything that can age out. */
|
|
21
|
+
export interface Timestamped {
|
|
22
|
+
readonly at: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EvictionOutcome<T> {
|
|
26
|
+
readonly kept: readonly T[];
|
|
27
|
+
readonly evicted: readonly T[];
|
|
28
|
+
readonly reason: 'none' | 'cap' | 'retention' | 'cap+retention';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Applies retention first, then the size cap.
|
|
33
|
+
*
|
|
34
|
+
* Order matters: expiring stale entries before trimming means a burst of new
|
|
35
|
+
* entries does not evict fresh ones while stale ones sit protected by
|
|
36
|
+
* recency ordering.
|
|
37
|
+
*/
|
|
38
|
+
export function evict<T extends Timestamped>(
|
|
39
|
+
entries: readonly T[],
|
|
40
|
+
collection: CollectionName,
|
|
41
|
+
now: number,
|
|
42
|
+
): EvictionOutcome<T> {
|
|
43
|
+
const { maxEntries, retentionMs } = COLLECTION_CAPS[collection];
|
|
44
|
+
|
|
45
|
+
const expired: T[] = [];
|
|
46
|
+
const live: T[] = [];
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (Number.isFinite(retentionMs) && now - entry.at > retentionMs) {
|
|
49
|
+
expired.push(entry);
|
|
50
|
+
} else {
|
|
51
|
+
live.push(entry);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Oldest first, so the tail is what we keep when trimming.
|
|
56
|
+
live.sort((a, b) => a.at - b.at);
|
|
57
|
+
|
|
58
|
+
const overflow = live.length > maxEntries ? live.slice(0, live.length - maxEntries) : [];
|
|
59
|
+
const kept = live.length > maxEntries ? live.slice(live.length - maxEntries) : live;
|
|
60
|
+
|
|
61
|
+
let reason: EvictionOutcome<T>['reason'] = 'none';
|
|
62
|
+
if (expired.length > 0 && overflow.length > 0) {
|
|
63
|
+
reason = 'cap+retention';
|
|
64
|
+
} else if (overflow.length > 0) {
|
|
65
|
+
reason = 'cap';
|
|
66
|
+
} else if (expired.length > 0) {
|
|
67
|
+
reason = 'retention';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { kept, evicted: [...expired, ...overflow], reason };
|
|
71
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The push deduplication ledger.
|
|
3
|
+
*
|
|
4
|
+
* Providers redeliver. The redelivery typically arrives in a **fresh process**,
|
|
5
|
+
* which is precisely why this is persisted rather than held in memory — an
|
|
6
|
+
* in-memory set would forget exactly when it matters.
|
|
7
|
+
*
|
|
8
|
+
* Bounded by cap and retention. A payload redelivered after its entry has aged
|
|
9
|
+
* out is treated as new: that is the documented bound of the guarantee, not a
|
|
10
|
+
* defect.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { StateManager } from './record';
|
|
14
|
+
|
|
15
|
+
export function isDuplicate(state: StateManager, dedupId: string): boolean {
|
|
16
|
+
return state.read().dedupLedger.some(entry => entry.dedupId === dedupId);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Records a payload as seen.
|
|
21
|
+
*
|
|
22
|
+
* Returns true when it was already known, so a caller can branch on the same
|
|
23
|
+
* read that records it rather than checking twice.
|
|
24
|
+
*/
|
|
25
|
+
export function recordSeen(
|
|
26
|
+
state: StateManager,
|
|
27
|
+
dedupId: string,
|
|
28
|
+
now: number,
|
|
29
|
+
ownerTag?: string,
|
|
30
|
+
): boolean {
|
|
31
|
+
if (isDuplicate(state, dedupId)) return true;
|
|
32
|
+
|
|
33
|
+
state.update('dedupLedger', current => [...current, { at: now, dedupId, ownerTag }]);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function dedupCount(state: StateManager): number {
|
|
38
|
+
return state.read().dedupLedger.length;
|
|
39
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable interaction buffer.
|
|
3
|
+
*
|
|
4
|
+
* A cold-start tap arrives before any handler exists. Holding it in memory
|
|
5
|
+
* would be enough if the process always survived — but the process is exactly
|
|
6
|
+
* what does not survive a cold start, so the buffer is persisted.
|
|
7
|
+
*
|
|
8
|
+
* This is the one content-bearing thing the package stores: an interaction
|
|
9
|
+
* cannot be delivered without its routing payload. It is evicted the moment it
|
|
10
|
+
* is delivered, and time-limited regardless, so an event nobody ever collects
|
|
11
|
+
* cannot sit on disk forever.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { StateManager, BufferedInteraction } from './record';
|
|
15
|
+
import type { InteractionEvent } from '../contract/interaction';
|
|
16
|
+
|
|
17
|
+
/** Identity for a buffered event, derived so re-buffering cannot duplicate. */
|
|
18
|
+
function bufferedId(event: InteractionEvent): string {
|
|
19
|
+
return `${event.kind}:${event.notificationId}:${event.actionId ?? ''}:${event.occurredAt}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buffer(state: StateManager, event: InteractionEvent): void {
|
|
23
|
+
const id = bufferedId(event);
|
|
24
|
+
|
|
25
|
+
state.update('interactionBuffer', current => {
|
|
26
|
+
if (current.some(entry => entry.id === id)) return current;
|
|
27
|
+
|
|
28
|
+
const entry: BufferedInteraction = {
|
|
29
|
+
at: event.occurredAt,
|
|
30
|
+
id,
|
|
31
|
+
kind: event.kind,
|
|
32
|
+
notificationId: event.notificationId,
|
|
33
|
+
actionId: event.actionId,
|
|
34
|
+
inputText: event.inputText,
|
|
35
|
+
routing: event.routing,
|
|
36
|
+
ownerTag: event.ownerTag,
|
|
37
|
+
};
|
|
38
|
+
return [...current, entry];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Takes every buffered event in occurrence order and clears them.
|
|
44
|
+
*
|
|
45
|
+
* Draining and clearing together is deliberate: delivering an event twice is
|
|
46
|
+
* worse than the alternative, because a consumer that acts on an interaction
|
|
47
|
+
* (marking a task done, sending a reply) would act twice.
|
|
48
|
+
*/
|
|
49
|
+
export function drain(state: StateManager): readonly InteractionEvent[] {
|
|
50
|
+
const buffered = [...state.read().interactionBuffer].sort((a, b) => a.at - b.at);
|
|
51
|
+
|
|
52
|
+
if (buffered.length === 0) return [];
|
|
53
|
+
|
|
54
|
+
state.update('interactionBuffer', () => []);
|
|
55
|
+
|
|
56
|
+
return buffered.map(entry => ({
|
|
57
|
+
kind: entry.kind,
|
|
58
|
+
notificationId: entry.notificationId,
|
|
59
|
+
actionId: entry.actionId,
|
|
60
|
+
inputText: entry.inputText,
|
|
61
|
+
routing: entry.routing,
|
|
62
|
+
ownerTag: entry.ownerTag,
|
|
63
|
+
occurredAt: entry.at,
|
|
64
|
+
// The state it arrived in is gone; what matters to a consumer is that it
|
|
65
|
+
// waited, which is what `wasBuffered` says.
|
|
66
|
+
appState: 'coldStart' as const,
|
|
67
|
+
wasBuffered: true,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function bufferedCount(state: StateManager): number {
|
|
72
|
+
return state.read().interactionBuffer.length;
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MMKV-backed state store.
|
|
3
|
+
*
|
|
4
|
+
* Every MMKV import is confined to this file, so the rest of the package stays
|
|
5
|
+
* testable without a native module.
|
|
6
|
+
*
|
|
7
|
+
* Backup exclusion is achieved by *where* the data lives rather than by setting
|
|
8
|
+
* a file attribute: on iOS the instance is rooted under `Library/Caches`, which
|
|
9
|
+
* the platform excludes from iCloud and iTunes backup by design. The OS may
|
|
10
|
+
* purge that directory under storage pressure — acceptable here precisely
|
|
11
|
+
* because discarding and rebuilding this record is already a specified,
|
|
12
|
+
* non-blocking condition.
|
|
13
|
+
*
|
|
14
|
+
* On Android, backup exclusion is declared in the consuming app's
|
|
15
|
+
* `data_extraction_rules.xml` and `full_backup_content`, documented in the
|
|
16
|
+
* README. It is the app's manifest, so it is the app's declaration to make.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Platform } from 'react-native';
|
|
20
|
+
import type { StateStore } from './store';
|
|
21
|
+
import { createMemoryStore } from './store';
|
|
22
|
+
|
|
23
|
+
const INSTANCE_ID = 'chipmobilesdk.notification';
|
|
24
|
+
|
|
25
|
+
interface MmkvLike {
|
|
26
|
+
getString(key: string): string | undefined;
|
|
27
|
+
set(key: string, value: string): void;
|
|
28
|
+
delete(key: string): void;
|
|
29
|
+
clearAll(): void;
|
|
30
|
+
getAllKeys(): string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates the persistent store, falling back to memory when MMKV is absent.
|
|
35
|
+
*
|
|
36
|
+
* The fallback is deliberate: a missing optional native module degrades
|
|
37
|
+
* deduplication and cold-start buffering to session scope, which is a
|
|
38
|
+
* documented degradation. It must never stop notifications being delivered.
|
|
39
|
+
*/
|
|
40
|
+
export function createMmkvStore(): StateStore {
|
|
41
|
+
let instance: MmkvLike;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Required lazily so the package loads in environments without the native module.
|
|
45
|
+
const { MMKV } = require('react-native-mmkv') as {
|
|
46
|
+
MMKV: new (config: { id: string; path?: string }) => MmkvLike;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
instance = new MMKV({
|
|
50
|
+
id: INSTANCE_ID,
|
|
51
|
+
// Caches is backup-excluded by platform design; see the note above.
|
|
52
|
+
path: Platform.OS === 'ios' ? 'Library/Caches/chipmobilesdk-notification' : undefined,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
return createMemoryStore();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
getString: key => instance.getString(key),
|
|
60
|
+
setString: (key, value) => instance.set(key, value),
|
|
61
|
+
delete: key => instance.delete(key),
|
|
62
|
+
clearAll: () => instance.clearAll(),
|
|
63
|
+
approximateBytes: () => {
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (const key of instance.getAllKeys()) {
|
|
66
|
+
total += key.length + (instance.getString(key)?.length ?? 0);
|
|
67
|
+
}
|
|
68
|
+
return total;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-request history.
|
|
3
|
+
*
|
|
4
|
+
* The platform cannot tell you whether you have already asked. On both
|
|
5
|
+
* platforms a second request after a denial is silently a no-op, so an app that
|
|
6
|
+
* does not remember will keep "asking" and keep appearing to be refused.
|
|
7
|
+
*
|
|
8
|
+
* Keyed by owner tag so a multi-account device can clear one account's history
|
|
9
|
+
* on sign-out without forgetting what it asked the others.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StateManager } from './record';
|
|
13
|
+
import type { PermissionKind } from '../contract/permission';
|
|
14
|
+
|
|
15
|
+
function key(kind: PermissionKind, ownerTag?: string): string {
|
|
16
|
+
return `${ownerTag ?? '*'}:${kind}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hasRequested(
|
|
20
|
+
state: StateManager,
|
|
21
|
+
kind: PermissionKind,
|
|
22
|
+
ownerTag?: string,
|
|
23
|
+
): boolean {
|
|
24
|
+
const target = key(kind, ownerTag);
|
|
25
|
+
return state.read().permissionHistory.some(entry => entry.key === target && entry.requested);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function recordRequested(
|
|
29
|
+
state: StateManager,
|
|
30
|
+
kind: PermissionKind,
|
|
31
|
+
now: number,
|
|
32
|
+
ownerTag?: string,
|
|
33
|
+
): void {
|
|
34
|
+
const target = key(kind, ownerTag);
|
|
35
|
+
if (hasRequested(state, kind, ownerTag)) return;
|
|
36
|
+
|
|
37
|
+
state.update('permissionHistory', current => [
|
|
38
|
+
...current.filter(entry => entry.key !== target),
|
|
39
|
+
{ at: now, key: target, requested: true },
|
|
40
|
+
]);
|
|
41
|
+
}
|