@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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tone-to-platform mapping.
|
|
3
|
+
*
|
|
4
|
+
* A tone is declared once and realized on each platform here, so consumer code
|
|
5
|
+
* contains zero platform branches. The two platforms model urgency differently
|
|
6
|
+
* — Android by channel importance, iOS by interruption level — and reconciling
|
|
7
|
+
* that difference is exactly the work this package exists to absorb.
|
|
8
|
+
*
|
|
9
|
+
* The channel id carries the tone version. Android channels are immutable once
|
|
10
|
+
* created and re-declaration is silently ignored, so a configuration change has
|
|
11
|
+
* to produce a *new* channel or it will never reach a device that already holds
|
|
12
|
+
* the old one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ResolvedTone, ToneDeclaration, ToneUrgency } from '../contract/channel';
|
|
16
|
+
|
|
17
|
+
type AndroidImportance = ResolvedTone['android']['importance'];
|
|
18
|
+
type IosInterruption = ResolvedTone['ios']['interruptionLevel'];
|
|
19
|
+
|
|
20
|
+
const ANDROID_IMPORTANCE: Record<ToneUrgency, AndroidImportance> = {
|
|
21
|
+
min: 'min',
|
|
22
|
+
low: 'low',
|
|
23
|
+
default: 'default',
|
|
24
|
+
high: 'high',
|
|
25
|
+
// Android has no level above `high`; max urgency is expressed by the DND
|
|
26
|
+
// bypass rather than by a distinct importance.
|
|
27
|
+
max: 'high',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const IOS_INTERRUPTION: Record<ToneUrgency, IosInterruption> = {
|
|
31
|
+
min: 'passive',
|
|
32
|
+
low: 'passive',
|
|
33
|
+
default: 'active',
|
|
34
|
+
high: 'active',
|
|
35
|
+
// `timeSensitive` is the highest level available without the Critical Alerts
|
|
36
|
+
// entitlement, which is out of scope for v1.
|
|
37
|
+
max: 'timeSensitive',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Channel id for a tone at a given version. Stable, derived, never random. */
|
|
41
|
+
export function channelIdFor(toneId: string, version: number): string {
|
|
42
|
+
return `${toneId}.v${version}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extracts the tone and version encoded in a channel id, if it is one of ours. */
|
|
46
|
+
export function parseChannelId(
|
|
47
|
+
channelId: string,
|
|
48
|
+
): { readonly toneId: string; readonly version: number } | undefined {
|
|
49
|
+
const separator = channelId.lastIndexOf('.v');
|
|
50
|
+
if (separator === -1) return undefined;
|
|
51
|
+
|
|
52
|
+
const version = Number.parseInt(channelId.slice(separator + 2), 10);
|
|
53
|
+
if (!Number.isInteger(version) || version < 1) return undefined;
|
|
54
|
+
|
|
55
|
+
return { toneId: channelId.slice(0, separator), version };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveTone(tone: ToneDeclaration): ResolvedTone {
|
|
59
|
+
const silent = tone.sound?.enabled === false;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
toneId: tone.id,
|
|
63
|
+
version: tone.version,
|
|
64
|
+
android: {
|
|
65
|
+
channelId: channelIdFor(tone.id, tone.version),
|
|
66
|
+
importance: ANDROID_IMPORTANCE[tone.urgency],
|
|
67
|
+
sound: silent ? undefined : tone.sound?.name,
|
|
68
|
+
vibration: tone.vibration ?? false,
|
|
69
|
+
badge: tone.badge ?? false,
|
|
70
|
+
bypassDnd: tone.bypassDoNotDisturb ?? false,
|
|
71
|
+
groupId: tone.groupId,
|
|
72
|
+
},
|
|
73
|
+
ios: {
|
|
74
|
+
// iOS categories are not versioned: they carry actions rather than
|
|
75
|
+
// user-adjustable settings, so re-declaring one is harmless.
|
|
76
|
+
categoryId: tone.id,
|
|
77
|
+
interruptionLevel: IOS_INTERRUPTION[tone.urgency],
|
|
78
|
+
sound: silent ? undefined : tone.sound?.name,
|
|
79
|
+
badge: tone.badge ?? false,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Builds a lookup over a declared configuration. */
|
|
85
|
+
export function createToneResolver(
|
|
86
|
+
tones: readonly ToneDeclaration[],
|
|
87
|
+
): (toneId: string) => ResolvedTone | undefined {
|
|
88
|
+
const resolved = new Map(tones.map(tone => [tone.id, resolveTone(tone)]));
|
|
89
|
+
return toneId => resolved.get(toneId);
|
|
90
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel, category, and tone declaration types.
|
|
3
|
+
*
|
|
4
|
+
* A tone is declared once and mapped by the package to each platform's
|
|
5
|
+
* equivalent concepts, so consumer code contains no platform branches.
|
|
6
|
+
*
|
|
7
|
+
* Two tones must never be merged into one: channel configuration belongs to
|
|
8
|
+
* the user, and collapsing two tones collapses two independent user choices.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How insistently a notification asks for attention. */
|
|
12
|
+
export type ToneUrgency = 'min' | 'low' | 'default' | 'high' | 'max';
|
|
13
|
+
|
|
14
|
+
export interface ToneSound {
|
|
15
|
+
/** Consumer-supplied resource name, resolved by the platform. */
|
|
16
|
+
readonly name?: string;
|
|
17
|
+
/** When false, the tone is silent. Defaults to true. */
|
|
18
|
+
readonly enabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ToneDeclaration {
|
|
22
|
+
/** Stable tone identifier, subject to the same charset rules as a notification id. */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** Human-readable name shown in system settings. */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly description?: string;
|
|
27
|
+
readonly urgency: ToneUrgency;
|
|
28
|
+
readonly sound?: ToneSound;
|
|
29
|
+
readonly vibration?: boolean;
|
|
30
|
+
readonly badge?: boolean;
|
|
31
|
+
/** Optional grouping in system settings. */
|
|
32
|
+
readonly groupId?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Request the platform's Do Not Disturb bypass. Honoured where the platform
|
|
35
|
+
* permits it and reported honestly where it does not. This is *not* an
|
|
36
|
+
* override of the device's silent mode, which is out of scope.
|
|
37
|
+
*/
|
|
38
|
+
readonly bypassDoNotDisturb?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Incremented by the consumer when any field above changes.
|
|
41
|
+
*
|
|
42
|
+
* Android channels are immutable once created and re-declaration is silently
|
|
43
|
+
* ignored, so a configuration change is a migration rather than an
|
|
44
|
+
* assignment. This version is what makes the migration detectable.
|
|
45
|
+
*/
|
|
46
|
+
readonly version: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ChannelGroup {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly description?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a tone resolves to on each platform. Internal to the engine adapter. */
|
|
56
|
+
export interface ResolvedTone {
|
|
57
|
+
readonly toneId: string;
|
|
58
|
+
readonly version: number;
|
|
59
|
+
readonly android: {
|
|
60
|
+
/** Platform channel id, derived from tone id and version so a change creates a new channel. */
|
|
61
|
+
readonly channelId: string;
|
|
62
|
+
readonly importance: 'none' | 'min' | 'low' | 'default' | 'high';
|
|
63
|
+
readonly sound?: string;
|
|
64
|
+
readonly vibration: boolean;
|
|
65
|
+
readonly badge: boolean;
|
|
66
|
+
readonly bypassDnd: boolean;
|
|
67
|
+
readonly groupId?: string;
|
|
68
|
+
};
|
|
69
|
+
readonly ios: {
|
|
70
|
+
readonly categoryId: string;
|
|
71
|
+
readonly interruptionLevel: 'passive' | 'active' | 'timeSensitive' | 'critical';
|
|
72
|
+
readonly sound?: string;
|
|
73
|
+
readonly badge: boolean;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Outcome of applying a channel configuration, including any migration performed. */
|
|
78
|
+
export interface ChannelApplyResult {
|
|
79
|
+
readonly created: readonly string[];
|
|
80
|
+
readonly migrated: readonly { readonly toneId: string; readonly from: number; readonly to: number }[];
|
|
81
|
+
readonly retired: readonly string[];
|
|
82
|
+
/** Notifications moved to the new channel during migration. */
|
|
83
|
+
readonly reHomed: readonly string[];
|
|
84
|
+
/** Notifications cancelled because they could not be re-homed. */
|
|
85
|
+
readonly cancelled: readonly string[];
|
|
86
|
+
/** Tones whose requested DND bypass the platform refused. */
|
|
87
|
+
readonly bypassUnavailable: readonly string[];
|
|
88
|
+
readonly unchanged: readonly string[];
|
|
89
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics and configuration types.
|
|
3
|
+
*
|
|
4
|
+
* The package's stated purpose is to make invisible notification failures
|
|
5
|
+
* diagnosable, so every lifecycle transition emits a structured event with a
|
|
6
|
+
* stable identifier — and none of those events carries content by default.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PermissionReport, RestrictionReport } from './permission';
|
|
10
|
+
import type { PendingDiscrepancy, MaintenanceResult, ReconcileResult } from './reconcile';
|
|
11
|
+
import type { ToneDeclaration, ChannelGroup } from './channel';
|
|
12
|
+
import type { LookaheadConfig } from './recurrence';
|
|
13
|
+
import type { PendingEntry } from './notification';
|
|
14
|
+
|
|
15
|
+
export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error';
|
|
16
|
+
|
|
17
|
+
export interface DiagnosticEvent {
|
|
18
|
+
/** Stable identifier, safe to switch on. See contracts/errors.md. */
|
|
19
|
+
readonly code: string;
|
|
20
|
+
readonly severity: DiagnosticSeverity;
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly at: number;
|
|
23
|
+
/**
|
|
24
|
+
* Structured context. Never contains notification body text, routing payload
|
|
25
|
+
* values, or push tokens unless verbose diagnostics is explicitly enabled.
|
|
26
|
+
*/
|
|
27
|
+
readonly context?: Readonly<Record<string, unknown>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type EventSink = (event: DiagnosticEvent) => void;
|
|
31
|
+
|
|
32
|
+
/** Health of one persisted collection against its cap. */
|
|
33
|
+
export interface CollectionHealth {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly size: number;
|
|
36
|
+
readonly cap: number;
|
|
37
|
+
readonly oldestEntryAt?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StateHealth {
|
|
41
|
+
readonly readable: boolean;
|
|
42
|
+
/** True when the record was discarded and rebuilt, e.g. after corruption. */
|
|
43
|
+
readonly rebuilt: boolean;
|
|
44
|
+
readonly collections: readonly CollectionHealth[];
|
|
45
|
+
readonly approximateBytes: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The readable answer to "why didn't it arrive". */
|
|
49
|
+
export interface StateSnapshot {
|
|
50
|
+
readonly permissions: PermissionReport;
|
|
51
|
+
readonly restrictions: RestrictionReport;
|
|
52
|
+
readonly tones: readonly { readonly id: string; readonly appliedVersion: number }[];
|
|
53
|
+
readonly pendingCount: number;
|
|
54
|
+
readonly pending: readonly PendingEntry[];
|
|
55
|
+
readonly lastReconcile?: ReconcileResult;
|
|
56
|
+
readonly lastMaintenance?: MaintenanceResult;
|
|
57
|
+
readonly discrepancy?: PendingDiscrepancy;
|
|
58
|
+
readonly stateHealth: StateHealth;
|
|
59
|
+
/** Presence only. The token value is never exposed here or anywhere else. */
|
|
60
|
+
readonly pushTokenPresent: boolean;
|
|
61
|
+
readonly capturedAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Abnormal notification volume. Reported, never acted upon. */
|
|
65
|
+
export interface BurstThreshold {
|
|
66
|
+
readonly perMinute: number;
|
|
67
|
+
readonly perHour: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_BURST_THRESHOLD: BurstThreshold = {
|
|
71
|
+
perMinute: 10,
|
|
72
|
+
perHour: 60,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export interface NotificationConfig {
|
|
76
|
+
/** Every tone, category, and channel in one place. */
|
|
77
|
+
readonly tones: readonly ToneDeclaration[];
|
|
78
|
+
readonly channelGroups?: readonly ChannelGroup[];
|
|
79
|
+
readonly lookahead?: Partial<LookaheadConfig>;
|
|
80
|
+
/**
|
|
81
|
+
* Automatic maintenance covers only what the package alone can know:
|
|
82
|
+
* window refresh, expansion, re-anchoring, verification, permission re-read.
|
|
83
|
+
* Full desired-set reconciliation is never automatic. Defaults to true.
|
|
84
|
+
*/
|
|
85
|
+
readonly automaticMaintenance?: boolean;
|
|
86
|
+
readonly burstThreshold?: BurstThreshold;
|
|
87
|
+
/** With no sink supplied the package emits nothing rather than writing to a console. */
|
|
88
|
+
readonly eventSink?: EventSink;
|
|
89
|
+
/**
|
|
90
|
+
* Includes content values in diagnostics. Documented as unsuitable for
|
|
91
|
+
* production, and off by default.
|
|
92
|
+
*/
|
|
93
|
+
readonly verboseDiagnostics?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface InitializeResult {
|
|
97
|
+
readonly tonesApplied: number;
|
|
98
|
+
readonly migrationsRun: number;
|
|
99
|
+
readonly stateRebuilt: boolean;
|
|
100
|
+
readonly automaticMaintenance: boolean;
|
|
101
|
+
readonly durationMs: number;
|
|
102
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error taxonomy and stable diagnostic identifiers.
|
|
3
|
+
*
|
|
4
|
+
* Every failure condition carries its own identifier. None collapses into a
|
|
5
|
+
* generic failure — an error that says only "scheduling failed" reproduces the
|
|
6
|
+
* problem this package exists to solve.
|
|
7
|
+
*
|
|
8
|
+
* Identifiers are stable across releases and safe to switch on. Adding new ones
|
|
9
|
+
* is an additive minor change.
|
|
10
|
+
*
|
|
11
|
+
* See `specs/009-rn-notification-package/contracts/errors.md`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DIAGNOSTIC_CODES = {
|
|
15
|
+
// --- Validation: raised before any platform call -------------------------
|
|
16
|
+
INVALID_ID_EMPTY: 'INVALID_ID_EMPTY',
|
|
17
|
+
INVALID_ID_LENGTH: 'INVALID_ID_LENGTH',
|
|
18
|
+
INVALID_ID_CHARSET: 'INVALID_ID_CHARSET',
|
|
19
|
+
INVALID_OWNER_TAG: 'INVALID_OWNER_TAG',
|
|
20
|
+
INVALID_GROUP_TAG: 'INVALID_GROUP_TAG',
|
|
21
|
+
MOMENT_IN_PAST: 'MOMENT_IN_PAST',
|
|
22
|
+
UNDECLARED_TONE: 'UNDECLARED_TONE',
|
|
23
|
+
ROUTING_PAYLOAD_TOO_LARGE: 'ROUTING_PAYLOAD_TOO_LARGE',
|
|
24
|
+
CONTENT_TOO_LARGE: 'CONTENT_TOO_LARGE',
|
|
25
|
+
TOO_MANY_ACTIONS: 'TOO_MANY_ACTIONS',
|
|
26
|
+
INVALID_RECURRENCE: 'INVALID_RECURRENCE',
|
|
27
|
+
|
|
28
|
+
// --- Reconciliation ------------------------------------------------------
|
|
29
|
+
DUPLICATE_IDENTIFIER: 'DUPLICATE_IDENTIFIER',
|
|
30
|
+
TRUNCATED_OVER_CEILING: 'TRUNCATED_OVER_CEILING',
|
|
31
|
+
RECONCILE_INTERRUPTED: 'RECONCILE_INTERRUPTED',
|
|
32
|
+
PENDING_SET_DISCREPANCY: 'PENDING_SET_DISCREPANCY',
|
|
33
|
+
|
|
34
|
+
// --- Permissions ---------------------------------------------------------
|
|
35
|
+
DISPLAY_PERMISSION_DENIED: 'DISPLAY_PERMISSION_DENIED',
|
|
36
|
+
DISPLAY_PERMISSION_PERMANENT: 'DISPLAY_PERMISSION_PERMANENT',
|
|
37
|
+
EXACT_ALARM_UNAVAILABLE: 'EXACT_ALARM_UNAVAILABLE',
|
|
38
|
+
SETTINGS_UNAVAILABLE: 'SETTINGS_UNAVAILABLE',
|
|
39
|
+
BACKGROUND_RESTRICTED: 'BACKGROUND_RESTRICTED',
|
|
40
|
+
BACKGROUND_RESTRICTION_UNKNOWN: 'BACKGROUND_RESTRICTION_UNKNOWN',
|
|
41
|
+
|
|
42
|
+
// --- Channels ------------------------------------------------------------
|
|
43
|
+
CHANNEL_MIGRATION_REQUIRED: 'CHANNEL_MIGRATION_REQUIRED',
|
|
44
|
+
CHANNEL_MIGRATION_FAILED: 'CHANNEL_MIGRATION_FAILED',
|
|
45
|
+
CHANNEL_ORPHANS_FOUND: 'CHANNEL_ORPHANS_FOUND',
|
|
46
|
+
DND_BYPASS_UNAVAILABLE: 'DND_BYPASS_UNAVAILABLE',
|
|
47
|
+
|
|
48
|
+
// --- Interaction ---------------------------------------------------------
|
|
49
|
+
TARGET_MISSING: 'TARGET_MISSING',
|
|
50
|
+
INTERACTION_BUFFER_EVICTED: 'INTERACTION_BUFFER_EVICTED',
|
|
51
|
+
BACKGROUND_WINDOW_EXCEEDED: 'BACKGROUND_WINDOW_EXCEEDED',
|
|
52
|
+
|
|
53
|
+
// --- Push ----------------------------------------------------------------
|
|
54
|
+
PUSH_UNAVAILABLE: 'PUSH_UNAVAILABLE',
|
|
55
|
+
TOKEN_UNAVAILABLE: 'TOKEN_UNAVAILABLE',
|
|
56
|
+
TOKEN_INVALIDATION_FAILED: 'TOKEN_INVALIDATION_FAILED',
|
|
57
|
+
PAYLOAD_MALFORMED: 'PAYLOAD_MALFORMED',
|
|
58
|
+
PAYLOAD_VERSION_SKEW: 'PAYLOAD_VERSION_SKEW',
|
|
59
|
+
PAYLOAD_DUPLICATE: 'PAYLOAD_DUPLICATE',
|
|
60
|
+
PUSH_UNDELIVERABLE: 'PUSH_UNDELIVERABLE',
|
|
61
|
+
|
|
62
|
+
// --- Persisted state -----------------------------------------------------
|
|
63
|
+
STATE_CORRUPT_REBUILT: 'STATE_CORRUPT_REBUILT',
|
|
64
|
+
STATE_WRITE_FAILED: 'STATE_WRITE_FAILED',
|
|
65
|
+
STATE_CAP_EVICTED: 'STATE_CAP_EVICTED',
|
|
66
|
+
|
|
67
|
+
// --- Volume --------------------------------------------------------------
|
|
68
|
+
BURST_THRESHOLD_EXCEEDED: 'BURST_THRESHOLD_EXCEEDED',
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[keyof typeof DIAGNOSTIC_CODES];
|
|
72
|
+
|
|
73
|
+
/** Every identifier, for exhaustiveness tests and the documentation table. */
|
|
74
|
+
export const ALL_DIAGNOSTIC_CODES: readonly DiagnosticCode[] = Object.freeze(
|
|
75
|
+
Object.values(DIAGNOSTIC_CODES),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A typed package failure.
|
|
80
|
+
*
|
|
81
|
+
* Carries enough context to choose a recovery, and never carries notification
|
|
82
|
+
* body text, routing payload values, or token material. Where a value violated
|
|
83
|
+
* a rule, the *shape* of the violation is reported — a length, a character
|
|
84
|
+
* position — rather than the value itself.
|
|
85
|
+
*/
|
|
86
|
+
export class NotificationError extends Error {
|
|
87
|
+
readonly code: DiagnosticCode;
|
|
88
|
+
readonly context?: Readonly<Record<string, unknown>>;
|
|
89
|
+
|
|
90
|
+
constructor(code: DiagnosticCode, message: string, context?: Readonly<Record<string, unknown>>) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = 'NotificationError';
|
|
93
|
+
this.code = code;
|
|
94
|
+
this.context = context;
|
|
95
|
+
Object.setPrototypeOf(this, NotificationError.prototype);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isNotificationError(value: unknown): value is NotificationError {
|
|
100
|
+
return value instanceof NotificationError;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Conditions that are reported outcomes rather than failures: the operation
|
|
105
|
+
* succeeded, and something the caller needs to know happened alongside it.
|
|
106
|
+
*/
|
|
107
|
+
export const NON_FAILURE_CODES: readonly DiagnosticCode[] = Object.freeze([
|
|
108
|
+
DIAGNOSTIC_CODES.TRUNCATED_OVER_CEILING,
|
|
109
|
+
DIAGNOSTIC_CODES.PENDING_SET_DISCREPANCY,
|
|
110
|
+
DIAGNOSTIC_CODES.EXACT_ALARM_UNAVAILABLE,
|
|
111
|
+
DIAGNOSTIC_CODES.TARGET_MISSING,
|
|
112
|
+
DIAGNOSTIC_CODES.BURST_THRESHOLD_EXCEEDED,
|
|
113
|
+
DIAGNOSTIC_CODES.PAYLOAD_VERSION_SKEW,
|
|
114
|
+
DIAGNOSTIC_CODES.PAYLOAD_DUPLICATE,
|
|
115
|
+
DIAGNOSTIC_CODES.STATE_CAP_EVICTED,
|
|
116
|
+
DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT,
|
|
117
|
+
DIAGNOSTIC_CODES.BACKGROUND_RESTRICTION_UNKNOWN,
|
|
118
|
+
DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
export function isNonFailure(code: DiagnosticCode): boolean {
|
|
122
|
+
return NON_FAILURE_CODES.includes(code);
|
|
123
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Public contract types. Types only — no logic lives in this layer. */
|
|
2
|
+
|
|
3
|
+
export * from './notification';
|
|
4
|
+
export * from './recurrence';
|
|
5
|
+
export * from './reconcile';
|
|
6
|
+
export * from './permission';
|
|
7
|
+
export * from './channel';
|
|
8
|
+
export * from './interaction';
|
|
9
|
+
export * from './diagnostics';
|
|
10
|
+
export * from './errors';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction types.
|
|
3
|
+
*
|
|
4
|
+
* One event shape covers foreground, background, and cold start, so a consumer
|
|
5
|
+
* writes one handler. Events that occur before a handler registers are buffered
|
|
6
|
+
* durably and delivered on registration — the cold-start case is the one most
|
|
7
|
+
* integrations quietly get wrong.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RoutingPayload } from './notification';
|
|
11
|
+
|
|
12
|
+
export type InteractionKind = 'tap' | 'action' | 'dismiss';
|
|
13
|
+
|
|
14
|
+
/** Which app state the interaction arrived in. Informational; the shape never varies. */
|
|
15
|
+
export type AppStateAtInteraction = 'foreground' | 'background' | 'coldStart';
|
|
16
|
+
|
|
17
|
+
export interface InteractionEvent {
|
|
18
|
+
readonly kind: InteractionKind;
|
|
19
|
+
readonly notificationId: string;
|
|
20
|
+
/** Present when `kind` is `action`. */
|
|
21
|
+
readonly actionId?: string;
|
|
22
|
+
/** Present when the action accepted typed input. */
|
|
23
|
+
readonly inputText?: string;
|
|
24
|
+
/** Round-trips unchanged from schedule time. */
|
|
25
|
+
readonly routing?: RoutingPayload;
|
|
26
|
+
readonly ownerTag?: string;
|
|
27
|
+
readonly occurredAt: number;
|
|
28
|
+
readonly appState: AppStateAtInteraction;
|
|
29
|
+
/**
|
|
30
|
+
* True when the event was held in the durable buffer before a handler
|
|
31
|
+
* existed to receive it — including across a process death.
|
|
32
|
+
*/
|
|
33
|
+
readonly wasBuffered: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Signals the routing target no longer exists.
|
|
38
|
+
*
|
|
39
|
+
* The consumer chooses its own fallback screen. A platform error is never
|
|
40
|
+
* shown to the user for this — a notification pointing at deleted data is an
|
|
41
|
+
* ordinary condition, not a crash.
|
|
42
|
+
*/
|
|
43
|
+
export interface TargetMissingSignal {
|
|
44
|
+
readonly notificationId: string;
|
|
45
|
+
readonly routing?: RoutingPayload;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type InteractionHandler = (event: InteractionEvent) => void | Promise<void>;
|
|
49
|
+
|
|
50
|
+
export type Unsubscribe = () => void;
|
|
51
|
+
|
|
52
|
+
/** Limits on the durable interaction buffer, so it can never grow without bound. */
|
|
53
|
+
export const INTERACTION_BUFFER_LIMITS = {
|
|
54
|
+
maxEntries: 50,
|
|
55
|
+
retentionMs: 7 * 24 * 60 * 60 * 1000,
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
/** Maximum size of a routing payload, validated at schedule time rather than discovered at tap time. */
|
|
59
|
+
export const MAX_ROUTING_PAYLOAD_BYTES = 4 * 1024;
|
|
60
|
+
|
|
61
|
+
/** Maximum action buttons on one notification. */
|
|
62
|
+
export const MAX_ACTIONS_PER_NOTIFICATION = 10;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core notification request types.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here carries business meaning. The package accepts fully-described
|
|
5
|
+
* requests and is responsible only for delivering them at the right moment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How a delivery moment is pinned to the passage of time.
|
|
10
|
+
*
|
|
11
|
+
* - `wallClock` — the local clock time named. An 08:00 reminder stays 08:00
|
|
12
|
+
* after the user crosses a time zone. This is the default, because reminders
|
|
13
|
+
* are wall-clock things and a caller who does not choose should get what
|
|
14
|
+
* their users expect.
|
|
15
|
+
* - `absolute` — one fixed instant worldwide, unaffected by zone changes.
|
|
16
|
+
*/
|
|
17
|
+
export type AnchoringMode = 'wallClock' | 'absolute';
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_ANCHORING_MODE: AnchoringMode = 'wallClock';
|
|
20
|
+
|
|
21
|
+
/** When a notification should be presented, and how that moment is anchored. */
|
|
22
|
+
export interface DeliveryMoment {
|
|
23
|
+
/** Epoch milliseconds. Interpreted per {@link DeliveryMoment.anchoring}. */
|
|
24
|
+
readonly at: number;
|
|
25
|
+
/** Defaults to `wallClock` when omitted. */
|
|
26
|
+
readonly anchoring?: AnchoringMode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolved display strings. The package performs no translation, formatting,
|
|
31
|
+
* or locale resolution — only the consuming app knows its content model.
|
|
32
|
+
*/
|
|
33
|
+
export interface NotificationContent {
|
|
34
|
+
readonly title: string;
|
|
35
|
+
readonly body: string;
|
|
36
|
+
/** Optional short text shown in collapsed form on some platforms. */
|
|
37
|
+
readonly subtitle?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Structured data carried to the interaction handler, unchanged. */
|
|
41
|
+
export type RoutingPayload = Readonly<Record<string, unknown>>;
|
|
42
|
+
|
|
43
|
+
/** A button on a notification. */
|
|
44
|
+
export interface Action {
|
|
45
|
+
/** Stable action identifier, subject to the same charset rules as a notification id. */
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly title: string;
|
|
48
|
+
/**
|
|
49
|
+
* `foreground` brings the app up; `background` is handled without opening it,
|
|
50
|
+
* inside the platform's bounded execution window.
|
|
51
|
+
*/
|
|
52
|
+
readonly mode: 'foreground' | 'background';
|
|
53
|
+
/** When present, the action accepts short typed input. */
|
|
54
|
+
readonly input?: {
|
|
55
|
+
readonly placeholder?: string;
|
|
56
|
+
readonly submitLabel?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The complete, self-contained description of one notification. */
|
|
61
|
+
export interface NotificationRequest {
|
|
62
|
+
/**
|
|
63
|
+
* Caller-supplied, stable, derived from the caller's own data.
|
|
64
|
+
* The package never generates, normalizes, or hashes it.
|
|
65
|
+
*/
|
|
66
|
+
readonly id: string;
|
|
67
|
+
readonly content: NotificationContent;
|
|
68
|
+
/** Must reference a tone declared in the package configuration. */
|
|
69
|
+
readonly tone: string;
|
|
70
|
+
readonly moment: DeliveryMoment;
|
|
71
|
+
readonly routing?: RoutingPayload;
|
|
72
|
+
readonly actions?: readonly Action[];
|
|
73
|
+
/** Opaque partition label. Never interpreted by the package. */
|
|
74
|
+
readonly ownerTag?: string;
|
|
75
|
+
/** Opaque cancellation group. Never interpreted by the package. */
|
|
76
|
+
readonly groupTag?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One entry the platform is currently holding, in enough detail to diff against intent. */
|
|
80
|
+
export interface PendingEntry {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
/** The instant the platform will actually fire, after anchoring resolution. */
|
|
83
|
+
readonly resolvedAt: number;
|
|
84
|
+
readonly anchoring: AnchoringMode;
|
|
85
|
+
/**
|
|
86
|
+
* The IANA zone this entry was last anchored against.
|
|
87
|
+
* `undefined` for absolute-instant entries, which are never anchored.
|
|
88
|
+
*
|
|
89
|
+
* Exists so the one case re-anchoring cannot fix — a zone change while the
|
|
90
|
+
* app never ran — is detectable rather than invisible.
|
|
91
|
+
*/
|
|
92
|
+
readonly anchoringGeneration?: string;
|
|
93
|
+
readonly content: NotificationContent;
|
|
94
|
+
readonly tone: string;
|
|
95
|
+
readonly routing?: RoutingPayload;
|
|
96
|
+
readonly actions?: readonly Action[];
|
|
97
|
+
readonly ownerTag?: string;
|
|
98
|
+
readonly groupTag?: string;
|
|
99
|
+
/** Present when this entry was expanded from a recurrence series. */
|
|
100
|
+
readonly seriesId?: string;
|
|
101
|
+
/** True when scheduled without exact-alarm capability; delivery may be late. */
|
|
102
|
+
readonly mayBeDelayed: boolean;
|
|
103
|
+
/** True when created by a push instruction rather than by app code. */
|
|
104
|
+
readonly pushCreated: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Outcome of a single schedule call. */
|
|
108
|
+
export interface ScheduleResult {
|
|
109
|
+
readonly id: string;
|
|
110
|
+
readonly resolvedAt: number;
|
|
111
|
+
readonly anchoring: AnchoringMode;
|
|
112
|
+
/** True when an existing notification under this identifier was replaced. */
|
|
113
|
+
readonly replaced: boolean;
|
|
114
|
+
/** True when the moment had just passed and was delivered inside the tolerance window. */
|
|
115
|
+
readonly deliveredImmediately: boolean;
|
|
116
|
+
/** True when scheduled in approximate mode for want of exact-alarm capability. */
|
|
117
|
+
readonly mayBeDelayed: boolean;
|
|
118
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission types.
|
|
3
|
+
*
|
|
4
|
+
* Display permission and exact-alarm permission are different things serving
|
|
5
|
+
* different purposes, so they are requested and reported independently. State
|
|
6
|
+
* is always readable without triggering a dialog.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type PermissionKind = 'display' | 'exactAlarm';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Six states, never collapsed into one another.
|
|
13
|
+
*
|
|
14
|
+
* - `notAsked` — distinguishable from `denied`, which is the whole point of
|
|
15
|
+
* being able to read state without prompting.
|
|
16
|
+
* - `provisional` — iOS quiet delivery. Neither granted nor denied.
|
|
17
|
+
* - `notRequired` — the running platform version has no such concept. Never
|
|
18
|
+
* reported as `denied`, which would imply a refusal that never happened.
|
|
19
|
+
*/
|
|
20
|
+
export type PermissionState =
|
|
21
|
+
| 'notAsked'
|
|
22
|
+
| 'granted'
|
|
23
|
+
| 'denied'
|
|
24
|
+
| 'permanentlyDenied'
|
|
25
|
+
| 'provisional'
|
|
26
|
+
| 'notRequired';
|
|
27
|
+
|
|
28
|
+
export interface PermissionReport {
|
|
29
|
+
readonly display: PermissionState;
|
|
30
|
+
readonly exactAlarm: PermissionState;
|
|
31
|
+
readonly readAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether a manufacturer-level restriction is in force, where it can be known. */
|
|
35
|
+
export type RestrictionStatus = 'unrestricted' | 'restricted' | 'unknown';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Manufacturer background restrictions, reported for information only.
|
|
39
|
+
*
|
|
40
|
+
* `unknown` is a first-class answer: some vendors stop apps in ways with no
|
|
41
|
+
* standard detection API, and guessing would be worse than admitting it.
|
|
42
|
+
* The package never changes any of these settings on the user's behalf.
|
|
43
|
+
*/
|
|
44
|
+
export interface RestrictionReport {
|
|
45
|
+
readonly batteryOptimization: RestrictionStatus;
|
|
46
|
+
readonly autoStart: RestrictionStatus;
|
|
47
|
+
readonly manufacturer?: string;
|
|
48
|
+
readonly readAt: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a permission request can even be presented on this platform version. */
|
|
52
|
+
export interface PermissionCapability {
|
|
53
|
+
readonly kind: PermissionKind;
|
|
54
|
+
readonly supported: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* True when the platform grants this only through system settings rather
|
|
57
|
+
* than an in-app dialog.
|
|
58
|
+
*/
|
|
59
|
+
readonly settingsOnly: boolean;
|
|
60
|
+
}
|