@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +306 -0
  3. package/package.json +73 -0
  4. package/src/channel/migrate.ts +211 -0
  5. package/src/channel/tone-map.ts +90 -0
  6. package/src/contract/channel.ts +89 -0
  7. package/src/contract/diagnostics.ts +102 -0
  8. package/src/contract/errors.ts +123 -0
  9. package/src/contract/index.ts +10 -0
  10. package/src/contract/interaction.ts +62 -0
  11. package/src/contract/notification.ts +118 -0
  12. package/src/contract/permission.ts +60 -0
  13. package/src/contract/reconcile.ts +88 -0
  14. package/src/contract/recurrence.ts +123 -0
  15. package/src/diagnostics/burst.ts +101 -0
  16. package/src/diagnostics/events.ts +72 -0
  17. package/src/diagnostics/redact.ts +72 -0
  18. package/src/diagnostics/snapshot.ts +64 -0
  19. package/src/engine/adapter.ts +79 -0
  20. package/src/engine/notify-kit-engine.ts +535 -0
  21. package/src/index.ts +173 -0
  22. package/src/initialize.ts +174 -0
  23. package/src/interaction/index.ts +146 -0
  24. package/src/maintenance/cycle.ts +147 -0
  25. package/src/maintenance/permission-refresh.ts +41 -0
  26. package/src/maintenance/reanchor.ts +95 -0
  27. package/src/maintenance/toggle.ts +60 -0
  28. package/src/maintenance/verify.ts +83 -0
  29. package/src/maintenance/window-refresh.ts +61 -0
  30. package/src/permission/index.ts +183 -0
  31. package/src/permission/state.ts +77 -0
  32. package/src/push/envelope.ts +165 -0
  33. package/src/push/fcm-provider.ts +146 -0
  34. package/src/push/gate.ts +190 -0
  35. package/src/push/index.ts +29 -0
  36. package/src/push/instructions.ts +109 -0
  37. package/src/push/provider-adapter.ts +54 -0
  38. package/src/push/signout.ts +65 -0
  39. package/src/reconcile/chunk.ts +53 -0
  40. package/src/reconcile/diff.ts +145 -0
  41. package/src/reconcile/duplicates.ts +73 -0
  42. package/src/reconcile/index.ts +175 -0
  43. package/src/reconcile/truncate.ts +56 -0
  44. package/src/recurrence/clock-guard.ts +71 -0
  45. package/src/recurrence/expand.ts +292 -0
  46. package/src/recurrence/index.ts +177 -0
  47. package/src/schedule/anchor.ts +267 -0
  48. package/src/schedule/index.ts +173 -0
  49. package/src/schedule/partition.ts +85 -0
  50. package/src/schedule/resolve-moment.ts +80 -0
  51. package/src/schedule/validate-content.ts +111 -0
  52. package/src/schedule/validate-identifier.ts +114 -0
  53. package/src/state/caps.ts +71 -0
  54. package/src/state/dedup-ledger.ts +39 -0
  55. package/src/state/interaction-buffer.ts +73 -0
  56. package/src/state/mmkv-store.ts +71 -0
  57. package/src/state/permission-history.ts +41 -0
  58. package/src/state/record.ts +246 -0
  59. package/src/state/reset.ts +41 -0
  60. package/src/state/series-ledger.ts +84 -0
  61. package/src/state/store.ts +40 -0
  62. package/src/testing/conformance/index.ts +194 -0
  63. package/src/testing/fake-engine.ts +284 -0
  64. package/src/testing/fake-provider.ts +129 -0
  65. package/src/testing/faults.ts +72 -0
  66. package/src/testing/index.ts +26 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Reconciliation types.
3
+ *
4
+ * A caller hands over the complete set its data currently implies; the package
5
+ * computes the minimal operation set that makes the device match. Running it
6
+ * twice with unchanged input performs zero platform operations — that
7
+ * idempotence is the package's central testable promise.
8
+ */
9
+
10
+ import type { NotificationRequest } from './notification';
11
+
12
+ export interface ReconcileInput {
13
+ /**
14
+ * Bounds what may be cancelled. Notifications outside this domain are never
15
+ * touched, including those scheduled by other parts of the app or by other
16
+ * libraries.
17
+ */
18
+ readonly domain: string;
19
+ readonly desired: readonly NotificationRequest[];
20
+ /**
21
+ * When false (the default), push-created notifications are left alone rather
22
+ * than cancelled for being absent from a locally-computed desired set.
23
+ */
24
+ readonly includePushCreated?: boolean;
25
+ }
26
+
27
+ /** Why a desired entry was refused rather than applied. */
28
+ export interface RejectedEntry {
29
+ readonly id: string;
30
+ readonly code: string;
31
+ readonly reason: string;
32
+ /** How many times this identifier appeared in the desired set. */
33
+ readonly occurrences: number;
34
+ /**
35
+ * True when a notification already scheduled under this identifier was left
36
+ * in place. Stated explicitly so a rejection is never misread as a
37
+ * cancellation.
38
+ */
39
+ readonly existingLeftInPlace: boolean;
40
+ }
41
+
42
+ /** What was dropped for exceeding the platform's pending ceiling. */
43
+ export interface TruncationReport {
44
+ readonly ceiling: number;
45
+ readonly droppedCount: number;
46
+ readonly droppedIds: readonly string[];
47
+ /**
48
+ * Dropped entries are not lost — they are re-evaluated at each window
49
+ * refresh as nearer entries deliver.
50
+ */
51
+ readonly reEvaluatedOnNextRefresh: true;
52
+ }
53
+
54
+ export interface ReconcileResult {
55
+ readonly domain: string;
56
+ readonly created: readonly string[];
57
+ readonly replaced: readonly string[];
58
+ readonly cancelled: readonly string[];
59
+ readonly unchanged: readonly string[];
60
+ readonly rejected: readonly RejectedEntry[];
61
+ readonly truncated?: TruncationReport;
62
+ /** Total platform operations issued. Zero on a no-change pass. */
63
+ readonly operationCount: number;
64
+ readonly durationMs: number;
65
+ }
66
+
67
+ /** A discrepancy between what the package expects and what the platform holds. */
68
+ export interface PendingDiscrepancy {
69
+ /** Expected from the series ledger or last reconciliation, but absent. */
70
+ readonly missing: readonly string[];
71
+ /** Present on the platform but not recognized by the package. */
72
+ readonly unrecognized: readonly string[];
73
+ readonly detectedAt: number;
74
+ }
75
+
76
+ /** Outcome of one automatic maintenance cycle. */
77
+ export interface MaintenanceResult {
78
+ readonly ranAt: number;
79
+ readonly windowRefreshed: boolean;
80
+ readonly occurrencesScheduled: number;
81
+ readonly reAnchored: number;
82
+ readonly timeZoneChanged: boolean;
83
+ readonly permissionChanged: boolean;
84
+ /** Detected and reported only — never repaired. */
85
+ readonly discrepancy?: PendingDiscrepancy;
86
+ readonly operationCount: number;
87
+ readonly durationMs: number;
88
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Recurrence declaration types.
3
+ *
4
+ * A caller declares a rule once; the package expands it inside a finite
5
+ * lookahead window. No caller ever writes an expansion loop, and no unbounded
6
+ * series is ever handed to the platform.
7
+ */
8
+
9
+ import type { AnchoringMode, NotificationContent, Action, RoutingPayload } from './notification';
10
+
11
+ /** Days of the week, `0` = Sunday, matching `Date.prototype.getDay`. */
12
+ export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
13
+
14
+ /** Every day at the declared clock time. */
15
+ export interface DailyPattern {
16
+ readonly kind: 'daily';
17
+ readonly hour: number;
18
+ readonly minute: number;
19
+ /** Fire every `interval` days. Defaults to 1. */
20
+ readonly interval?: number;
21
+ }
22
+
23
+ /** Selected weekdays at the declared clock time. */
24
+ export interface WeeklyPattern {
25
+ readonly kind: 'weekly';
26
+ readonly weekdays: readonly Weekday[];
27
+ readonly hour: number;
28
+ readonly minute: number;
29
+ /** Fire every `interval` weeks. Defaults to 1. */
30
+ readonly interval?: number;
31
+ }
32
+
33
+ /**
34
+ * A fixed day of the month at the declared clock time.
35
+ *
36
+ * Months shorter than `dayOfMonth` are skipped rather than clamped: a caller
37
+ * asking for the 31st means the 31st, and inventing the 28th would be the
38
+ * package deciding something it was not asked to decide.
39
+ */
40
+ export interface MonthlyByDayPattern {
41
+ readonly kind: 'monthlyByDay';
42
+ readonly dayOfMonth: number;
43
+ readonly hour: number;
44
+ readonly minute: number;
45
+ /** Fire every `interval` months. Defaults to 1. */
46
+ readonly interval?: number;
47
+ }
48
+
49
+ export type RecurrencePattern = DailyPattern | WeeklyPattern | MonthlyByDayPattern;
50
+
51
+ /** When a series stops producing occurrences. */
52
+ export type EndCondition =
53
+ | { readonly kind: 'unbounded' }
54
+ | { readonly kind: 'untilDate'; readonly until: number }
55
+ | { readonly kind: 'count'; readonly count: number };
56
+
57
+ /** Content shared by every occurrence of a series. Identity and moment are derived. */
58
+ export interface OccurrenceTemplate {
59
+ readonly content: NotificationContent;
60
+ readonly tone: string;
61
+ readonly routing?: RoutingPayload;
62
+ readonly actions?: readonly Action[];
63
+ readonly ownerTag?: string;
64
+ readonly groupTag?: string;
65
+ }
66
+
67
+ export interface RecurrenceRule {
68
+ /** Stable series identifier, subject to the same charset rules as a notification id. */
69
+ readonly id: string;
70
+ readonly pattern: RecurrencePattern;
71
+ /**
72
+ * Declared once for the whole series and inherited by every occurrence.
73
+ * A series may not mix modes. Defaults to `wallClock`.
74
+ */
75
+ readonly anchoring?: AnchoringMode;
76
+ /** The instant from which occurrences are generated. Defaults to now. */
77
+ readonly startAt?: number;
78
+ readonly end: EndCondition;
79
+ readonly template: OccurrenceTemplate;
80
+ }
81
+
82
+ /**
83
+ * How far ahead occurrences are registered with the platform.
84
+ *
85
+ * Bounded by both a duration and a count because either alone fails: a duration
86
+ * alone lets a frequent series exhaust the platform ceiling, and a count alone
87
+ * leaves an annual series scheduled a decade out.
88
+ */
89
+ export interface LookaheadConfig {
90
+ readonly days: number;
91
+ readonly occurrencesPerSeries: number;
92
+ }
93
+
94
+ export const DEFAULT_LOOKAHEAD: LookaheadConfig = {
95
+ days: 60,
96
+ occurrencesPerSeries: 32,
97
+ };
98
+
99
+ /** Bounds on consumer adjustment, because the platform ceiling is not negotiable. */
100
+ export const LOOKAHEAD_BOUNDS = {
101
+ minDays: 1,
102
+ maxDays: 365,
103
+ minOccurrences: 1,
104
+ maxOccurrences: 64,
105
+ } as const;
106
+
107
+ export interface SeriesResult {
108
+ readonly seriesId: string;
109
+ /** Occurrences registered with the platform in this pass. */
110
+ readonly scheduled: number;
111
+ /** The horizon expansion reached, in epoch milliseconds. */
112
+ readonly expandedThrough: number;
113
+ /** True when the series has produced its final occurrence. */
114
+ readonly exhausted: boolean;
115
+ }
116
+
117
+ export interface SeriesSummary {
118
+ readonly seriesId: string;
119
+ readonly anchoring: AnchoringMode;
120
+ readonly expandedThrough: number;
121
+ readonly pendingOccurrences: number;
122
+ readonly exhausted: boolean;
123
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Burst detection.
3
+ *
4
+ * Both platforms apply undocumented delivery rate limits and will silently drop
5
+ * or delay past them — which is exactly the invisible failure this package
6
+ * exists to eliminate. So volume is watched and reported.
7
+ *
8
+ * It is **not** acted upon. Nothing is throttled, delayed, coalesced, or
9
+ * dropped: deciding which of a user's notifications to suppress is a product
10
+ * decision the package has no basis to make. Noticing that something abnormal
11
+ * is happening is one it can.
12
+ *
13
+ * Counters are in memory only. A burst is a live condition, and persisting it
14
+ * would expand the state contract for no diagnostic gain.
15
+ */
16
+
17
+ import type { BurstThreshold } from '../contract/diagnostics';
18
+ import { DEFAULT_BURST_THRESHOLD } from '../contract/diagnostics';
19
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
20
+
21
+ const MINUTE_MS = 60_000;
22
+ const HOUR_MS = 60 * MINUTE_MS;
23
+
24
+ export interface BurstDetectorDependencies {
25
+ readonly threshold?: BurstThreshold;
26
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
27
+ }
28
+
29
+ export interface BurstStatus {
30
+ readonly ownerTag: string;
31
+ readonly lastMinute: number;
32
+ readonly lastHour: number;
33
+ readonly exceeded: boolean;
34
+ }
35
+
36
+ export function createBurstDetector(deps: BurstDetectorDependencies = {}) {
37
+ const threshold = deps.threshold ?? DEFAULT_BURST_THRESHOLD;
38
+ const emit = deps.emit ?? (() => {});
39
+ /** Per owner tag, because one busy account should not implicate another. */
40
+ const timestamps = new Map<string, number[]>();
41
+ /** Prevents one sustained burst producing a diagnostic per notification. */
42
+ const reportedAt = new Map<string, number>();
43
+
44
+ const prune = (tag: string, now: number): number[] => {
45
+ const kept = (timestamps.get(tag) ?? []).filter(at => now - at <= HOUR_MS);
46
+ timestamps.set(tag, kept);
47
+ return kept;
48
+ };
49
+
50
+ return {
51
+ /**
52
+ * Records one presented notification and reports if the threshold is crossed.
53
+ *
54
+ * Always returns after recording — the caller delivers regardless of what
55
+ * this says.
56
+ */
57
+ record(now: number, ownerTag = '*'): BurstStatus {
58
+ const kept = prune(ownerTag, now);
59
+ kept.push(now);
60
+
61
+ const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
62
+ const lastHour = kept.length;
63
+ const exceeded = lastMinute > threshold.perMinute || lastHour > threshold.perHour;
64
+
65
+ if (exceeded) {
66
+ const last = reportedAt.get(ownerTag) ?? 0;
67
+ // One report per minute per tag; a sustained burst is one condition,
68
+ // not a hundred.
69
+ if (now - last >= MINUTE_MS) {
70
+ reportedAt.set(ownerTag, now);
71
+ emit(
72
+ DIAGNOSTIC_CODES.BURST_THRESHOLD_EXCEEDED,
73
+ `Abnormal notification volume: ${lastMinute} in the last minute and ${lastHour} in the last hour (thresholds ${threshold.perMinute}/minute, ${threshold.perHour}/hour). Every notification was still delivered — the package throttles nothing.`,
74
+ { ownerTag, lastMinute, lastHour, threshold },
75
+ );
76
+ }
77
+ }
78
+
79
+ return { ownerTag, lastMinute, lastHour, exceeded };
80
+ },
81
+
82
+ status(now: number, ownerTag = '*'): BurstStatus {
83
+ const kept = prune(ownerTag, now);
84
+ const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
85
+
86
+ return {
87
+ ownerTag,
88
+ lastMinute,
89
+ lastHour: kept.length,
90
+ exceeded: lastMinute > threshold.perMinute || kept.length > threshold.perHour,
91
+ };
92
+ },
93
+
94
+ reset(): void {
95
+ timestamps.clear();
96
+ reportedAt.clear();
97
+ },
98
+ };
99
+ }
100
+
101
+ export type BurstDetector = ReturnType<typeof createBurstDetector>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The structured event stream.
3
+ *
4
+ * With no sink attached the package emits nothing and buffers nothing. A
5
+ * library that writes to the console in production has decided something for
6
+ * its consumer; this one routes events wherever the app already sends its logs,
7
+ * or stays silent.
8
+ */
9
+
10
+ import type { DiagnosticEvent, DiagnosticSeverity, EventSink } from '../contract/diagnostics';
11
+ import type { DiagnosticCode } from '../contract/errors';
12
+ import { isNonFailure } from '../contract/errors';
13
+ import { redactContext } from './redact';
14
+
15
+ export interface EmitterOptions {
16
+ readonly sink?: EventSink;
17
+ readonly verbose?: boolean;
18
+ readonly now?: () => number;
19
+ }
20
+
21
+ export class DiagnosticEmitter {
22
+ private sink?: EventSink;
23
+ private verbose: boolean;
24
+ private readonly now: () => number;
25
+
26
+ constructor(options: EmitterOptions = {}) {
27
+ this.sink = options.sink;
28
+ this.verbose = options.verbose ?? false;
29
+ this.now = options.now ?? (() => Date.now());
30
+ }
31
+
32
+ setSink(sink: EventSink | undefined): void {
33
+ this.sink = sink;
34
+ }
35
+
36
+ setVerbose(verbose: boolean): void {
37
+ this.verbose = verbose;
38
+ }
39
+
40
+ get hasSink(): boolean {
41
+ return this.sink !== undefined;
42
+ }
43
+
44
+ emit(
45
+ code: DiagnosticCode,
46
+ message: string,
47
+ context?: Readonly<Record<string, unknown>>,
48
+ severity?: DiagnosticSeverity,
49
+ ): void {
50
+ // No sink means no work at all — not a buffer that grows until one appears.
51
+ if (this.sink === undefined) return;
52
+
53
+ const event: DiagnosticEvent = {
54
+ code,
55
+ severity: severity ?? (isNonFailure(code) ? 'info' : 'error'),
56
+ message,
57
+ at: this.now(),
58
+ context: redactContext(context, this.verbose),
59
+ };
60
+
61
+ try {
62
+ this.sink(event);
63
+ } catch {
64
+ // A failing sink is the consumer's problem and must never become ours.
65
+ }
66
+ }
67
+
68
+ /** Lifecycle transitions that are not failures: scheduled, delivered, reconciled. */
69
+ info(code: DiagnosticCode, message: string, context?: Readonly<Record<string, unknown>>): void {
70
+ this.emit(code, message, context, 'info');
71
+ }
72
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Content redaction for diagnostics.
3
+ *
4
+ * A notification library that logs notification bodies has turned a diagnostic
5
+ * aid into a privacy incident. Nothing content-bearing reaches an event unless
6
+ * verbose diagnostics is explicitly enabled — and that setting is documented as
7
+ * unsuitable for production.
8
+ *
9
+ * Values are replaced by their *shape* — a length, a type, a character position
10
+ * — which is what actually helps someone debug a validation failure anyway.
11
+ */
12
+
13
+ /** Keys whose values are never emitted at any verbosity below verbose. */
14
+ const REDACTED_KEYS = new Set([
15
+ 'title',
16
+ 'body',
17
+ 'subtitle',
18
+ 'content',
19
+ 'routing',
20
+ 'routingPayload',
21
+ 'payload',
22
+ 'token',
23
+ 'pushToken',
24
+ 'inputText',
25
+ 'text',
26
+ 'data',
27
+ ]);
28
+
29
+ const REDACTED = '[redacted]';
30
+
31
+ function describe(value: unknown): string {
32
+ if (value === null) return '[null]';
33
+ if (value === undefined) return '[undefined]';
34
+ if (typeof value === 'string') return `[string:${value.length}]`;
35
+ if (typeof value === 'number' || typeof value === 'boolean') return REDACTED;
36
+ if (Array.isArray(value)) return `[array:${value.length}]`;
37
+ if (typeof value === 'object') return `[object:${Object.keys(value).length}]`;
38
+ return REDACTED;
39
+ }
40
+
41
+ /**
42
+ * Strips content values from a diagnostic context.
43
+ *
44
+ * Recurses so a nested `{ request: { content: { body } } }` is caught — a
45
+ * top-level-only scrub is the kind of redaction that leaks the first time
46
+ * anyone nests a payload.
47
+ */
48
+ export function redactContext(
49
+ context: Readonly<Record<string, unknown>> | undefined,
50
+ verbose: boolean,
51
+ ): Readonly<Record<string, unknown>> | undefined {
52
+ if (context === undefined) return undefined;
53
+ if (verbose) return context;
54
+
55
+ const walk = (value: unknown, depth: number): unknown => {
56
+ if (depth > 6 || value === null || typeof value !== 'object') return value;
57
+ if (Array.isArray(value)) return value.map(item => walk(item, depth + 1));
58
+
59
+ const output: Record<string, unknown> = {};
60
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
61
+ output[key] = REDACTED_KEYS.has(key) ? describe(inner) : walk(inner, depth + 1);
62
+ }
63
+ return output;
64
+ };
65
+
66
+ return walk(context, 0) as Readonly<Record<string, unknown>>;
67
+ }
68
+
69
+ /** True when a key would be redacted. Exported so tests can assert the policy directly. */
70
+ export function isRedactedKey(key: string): boolean {
71
+ return REDACTED_KEYS.has(key);
72
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The state snapshot.
3
+ *
4
+ * The readable answer to "why didn't it arrive?" — assembled in one place so
5
+ * that question can be answered without attaching a native debugger.
6
+ *
7
+ * The push token appears as **presence only**. Its value is never exposed here
8
+ * or anywhere else, because a snapshot is the thing most likely to be pasted
9
+ * into a bug report.
10
+ */
11
+
12
+ import type { StateSnapshot } from '../contract/diagnostics';
13
+ import type { PendingEntry } from '../contract/notification';
14
+ import type { PermissionReport, RestrictionReport } from '../contract/permission';
15
+ import type { MaintenanceResult, PendingDiscrepancy, ReconcileResult } from '../contract/reconcile';
16
+ import type { NotificationEngineAdapter } from '../engine/adapter';
17
+ import type { StateManager } from '../state/record';
18
+
19
+ export interface SnapshotDependencies {
20
+ readonly engine: NotificationEngineAdapter;
21
+ readonly state: StateManager;
22
+ readonly now: () => number;
23
+ readonly permissions: () => Promise<PermissionReport>;
24
+ readonly restrictions: () => Promise<RestrictionReport>;
25
+ readonly appliedTones: () => readonly { readonly toneId: string; readonly appliedVersion: number }[];
26
+ readonly lastReconcile: () => ReconcileResult | undefined;
27
+ readonly lastMaintenance: () => MaintenanceResult | undefined;
28
+ /** Presence only — never the value. */
29
+ readonly hasPushToken: () => boolean;
30
+ }
31
+
32
+ export function createSnapshot(deps: SnapshotDependencies) {
33
+ return {
34
+ async capture(): Promise<StateSnapshot> {
35
+ const [permissions, restrictions, pending] = await Promise.all([
36
+ deps.permissions(),
37
+ deps.restrictions(),
38
+ deps.engine.getPending(),
39
+ ]);
40
+
41
+ const maintenance = deps.lastMaintenance();
42
+ const discrepancy: PendingDiscrepancy | undefined = maintenance?.discrepancy;
43
+
44
+ return {
45
+ permissions,
46
+ restrictions,
47
+ tones: deps.appliedTones().map(tone => ({
48
+ id: tone.toneId,
49
+ appliedVersion: tone.appliedVersion,
50
+ })),
51
+ pendingCount: pending.length,
52
+ pending: pending as readonly PendingEntry[],
53
+ lastReconcile: deps.lastReconcile(),
54
+ lastMaintenance: maintenance,
55
+ discrepancy,
56
+ stateHealth: deps.state.health(),
57
+ pushTokenPresent: deps.hasPushToken(),
58
+ capturedAt: deps.now(),
59
+ };
60
+ },
61
+ };
62
+ }
63
+
64
+ export type Snapshot = ReturnType<typeof createSnapshot>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The port every local notification engine satisfies.
3
+ *
4
+ * No engine type appears in any signature here. That is what makes the engine
5
+ * replaceable — and it has already been exercised once, when the archived
6
+ * Notifee was swapped for its maintained fork with no consumer impact.
7
+ */
8
+
9
+ import type { PendingEntry, NotificationRequest } from '../contract/notification';
10
+ import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
11
+ import type { ResolvedTone, ChannelGroup } from '../contract/channel';
12
+ import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
13
+
14
+ /** A request whose anchoring has already been resolved to a firing instant. */
15
+ export interface EngineScheduleRequest {
16
+ readonly request: NotificationRequest;
17
+ /** The absolute instant the platform should fire, after anchoring resolution. */
18
+ readonly resolvedAt: number;
19
+ /** Resolved platform channel and category for the request's tone. */
20
+ readonly tone: ResolvedTone;
21
+ /**
22
+ * The IANA time zone this moment was anchored against.
23
+ *
24
+ * Carried explicitly because the adapter has to round-trip it through the
25
+ * platform data bag — `getPending` must report it (FR-010) so a consumer can
26
+ * detect the one case re-anchoring cannot fix (FR-007c). Absent for
27
+ * absolute-instant moments, which are never anchored to a zone at all.
28
+ */
29
+ readonly anchoringGeneration?: string;
30
+ /** True when exact-alarm capability is unavailable and approximate mode applies. */
31
+ readonly approximate: boolean;
32
+ readonly seriesId?: string;
33
+ readonly pushCreated?: boolean;
34
+ }
35
+
36
+ export interface EngineCapabilities {
37
+ /** iOS holds only the 64 soonest pending notifications and silently drops the rest. */
38
+ readonly pendingCeiling: number;
39
+ readonly supportsExactAlarms: boolean;
40
+ readonly supportsChannels: boolean;
41
+ readonly supportsTextInputActions: boolean;
42
+ readonly supportsDoNotDisturbBypass: boolean;
43
+ /** Milliseconds a background action or silent payload may run for. */
44
+ readonly backgroundWindowMs: number;
45
+ }
46
+
47
+ export interface NotificationEngineAdapter {
48
+ readonly name: string;
49
+
50
+ getCapabilities(): Promise<EngineCapabilities>;
51
+
52
+ /** Scheduling an existing identifier replaces it; exactly one entry results. */
53
+ schedule(request: EngineScheduleRequest): Promise<void>;
54
+
55
+ cancel(id: string): Promise<void>;
56
+ cancelMany(ids: readonly string[]): Promise<void>;
57
+ cancelAll(): Promise<void>;
58
+
59
+ /** Full detail, so callers can diff rather than merely count. */
60
+ getPending(): Promise<readonly PendingEntry[]>;
61
+
62
+ /** Applies a channel or category declaration. Returns true when the platform created it. */
63
+ declareTone(tone: ResolvedTone): Promise<boolean>;
64
+ /** Removes a retired channel. Returns true when one was actually removed. */
65
+ retireTone(channelId: string): Promise<boolean>;
66
+ declareGroups(groups: readonly ChannelGroup[]): Promise<void>;
67
+ /** Channel ids the platform currently holds, which re-declaration cannot reveal. */
68
+ listDeclaredChannels(): Promise<readonly string[]>;
69
+
70
+ getPermission(kind: PermissionKind): Promise<PermissionState>;
71
+ requestPermission(kind: PermissionKind): Promise<PermissionState>;
72
+ openSettings(kind: PermissionKind): Promise<void>;
73
+ getRestrictions(): Promise<RestrictionReport>;
74
+
75
+ /** Interactions arriving before a handler registers are the caller's problem to buffer, not the engine's. */
76
+ subscribeToInteractions(handler: (event: InteractionEvent) => void): Unsubscribe;
77
+ /** Any interaction that launched the app, consumed exactly once. */
78
+ takeInitialInteraction(): Promise<InteractionEvent | undefined>;
79
+ }