@chipmobilesdk/rn-notification 0.5.0 → 0.6.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 (37) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +100 -10
  3. package/package.json +6 -1
  4. package/skills/sdk-rn-notification/SKILL.md +6 -5
  5. package/src/advanced.ts +35 -0
  6. package/src/channel/migrate.ts +176 -158
  7. package/src/channel/tone-map.ts +2 -10
  8. package/src/contract/errors.ts +7 -0
  9. package/src/contract/hash.ts +26 -0
  10. package/src/engine/adapter.ts +21 -1
  11. package/src/engine/notify-kit/events.ts +155 -0
  12. package/src/engine/notify-kit/module.ts +148 -0
  13. package/src/engine/notify-kit/platform/android.ts +172 -0
  14. package/src/engine/notify-kit/platform/index.ts +55 -0
  15. package/src/engine/notify-kit/platform/ios.ts +54 -0
  16. package/src/engine/notify-kit/platform/types.ts +40 -0
  17. package/src/engine/notify-kit-engine.ts +96 -477
  18. package/src/index.ts +9 -0
  19. package/src/notifications.ts +195 -0
  20. package/src/permission/index.ts +65 -4
  21. package/src/push/fcm-provider.ts +14 -4
  22. package/src/push/gate.ts +27 -5
  23. package/src/reconcile/diff.ts +38 -27
  24. package/src/reconcile/index.ts +13 -4
  25. package/src/recurrence/index.ts +2 -1
  26. package/src/repeat/expiry.ts +2 -6
  27. package/src/repeat/index.ts +45 -1
  28. package/src/repeat/marker.ts +1 -22
  29. package/src/repeat/place.ts +74 -35
  30. package/src/schedule/index.ts +10 -4
  31. package/src/schedule/partition.ts +0 -19
  32. package/src/schedule/validate-identifier.ts +8 -6
  33. package/src/schedule/writer.ts +13 -4
  34. package/src/state/caps.ts +28 -3
  35. package/src/state/permission-history.ts +25 -0
  36. package/src/state/record.ts +110 -20
  37. package/src/testing/fake-engine.ts +17 -1
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The slice of `react-native-notify-kit` this package uses, and how it is loaded.
3
+ *
4
+ * Every import of the engine is confined to this file. The module is required
5
+ * lazily so the package loads in Jest and in any environment without the native
6
+ * module; an absent engine is reported through the normal diagnostic path
7
+ * rather than crashing at import time.
8
+ */
9
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
10
+
11
+ import { DIAGNOSTIC_CODES, NotificationError } from '../../contract/errors';
12
+
13
+ /** Both platforms bound background execution for a silent payload or background action. */
14
+ export const BACKGROUND_WINDOW_MS = 30_000;
15
+
16
+ /**
17
+ * The engine's own enum values, mirrored as literals.
18
+ *
19
+ * These are **numeric** enums in the engine's type definitions, and its native
20
+ * validator rejects the string names — `importance: 'default'` fails with
21
+ * "expected a valid Importance". Mirroring them here rather than importing the
22
+ * enums keeps the engine out of the module graph, which is what lets the
23
+ * package load without the native module present.
24
+ *
25
+ * The package's own `ResolvedTone` deliberately stays platform-neutral strings;
26
+ * translating to the engine's representation is this adapter's job and nobody
27
+ * else's.
28
+ */
29
+ export const ANDROID_IMPORTANCE = {
30
+ none: 0,
31
+ min: 1,
32
+ low: 2,
33
+ default: 3,
34
+ high: 4,
35
+ } as const;
36
+
37
+ /** `TriggerType.TIMESTAMP`. */
38
+ export const TRIGGER_TYPE_TIMESTAMP = 0;
39
+
40
+ /** `AlarmType.SET_EXACT_AND_ALLOW_WHILE_IDLE` — exact delivery, survives Doze. */
41
+ export const ALARM_TYPE_EXACT_AND_ALLOW_WHILE_IDLE = 3;
42
+
43
+ /** `AlarmType.SET_AND_ALLOW_WHILE_IDLE` — the approximate degradation of the above. */
44
+ export const ALARM_TYPE_INEXACT_AND_ALLOW_WHILE_IDLE = 1;
45
+
46
+ /**
47
+ * `EventType` values that represent a real user interaction.
48
+ *
49
+ * The engine emits far more than interactions on this channel — `DELIVERED`,
50
+ * `TRIGGER_NOTIFICATION_CREATED`, `CHANNEL_BLOCKED` and others all arrive here.
51
+ * Treating anything that is not a dismissal as a press would report a
52
+ * notification *arriving* while the app is open as the user having tapped it,
53
+ * and the interaction path cancels on tap — so the notification would vanish
54
+ * from the tray in front of the user, unread.
55
+ */
56
+ export const EVENT_TYPE_DISMISSED = 0;
57
+ export const EVENT_TYPE_PRESS = 1;
58
+ export const EVENT_TYPE_ACTION_PRESS = 2;
59
+
60
+ /** `EventType.DELIVERED` — an arrival, which is emphatically not an interaction. */
61
+ export const EVENT_TYPE_DELIVERED = 3;
62
+
63
+ /**
64
+ * `AndroidCategory.ALARM`, the engine's marking for a time-sensitive alert.
65
+ *
66
+ * A per-notification property, not channel configuration — which is why
67
+ * declaring `timeSensitive` on an existing tone never obliges a version bump.
68
+ */
69
+ export const ANDROID_CATEGORY_ALARM = 'alarm';
70
+
71
+ /**
72
+ * The channel stop markers are posted on.
73
+ *
74
+ * A marker exists to be delivered and immediately removed, so it is given the
75
+ * quietest configuration the platform offers: minimum importance, no sound, no
76
+ * vibration. Its version is fixed because nothing about it is consumer-declared,
77
+ * so it never migrates.
78
+ */
79
+ export const MARKER_CHANNEL_ID = 'cms.internal.stop.v1';
80
+
81
+ /** Minimal shape used from the engine, so its full surface never leaks into typing. */
82
+ export interface NotifyKitModule {
83
+ createChannel(channel: Record<string, unknown>): Promise<string>;
84
+ deleteChannel(channelId: string): Promise<void>;
85
+ /**
86
+ * Labels come back with the id. That is what makes a label refresh decidable
87
+ * against the device rather than against a ledger of our own — and a ledger
88
+ * drifts after a restore, a reinstall, or a change the user made themselves.
89
+ */
90
+ getChannels(): Promise<{ id: string; name?: string; description?: string }[]>;
91
+ createChannelGroup(group: Record<string, unknown>): Promise<string>;
92
+ createTriggerNotification(
93
+ notification: Record<string, unknown>,
94
+ trigger: Record<string, unknown>,
95
+ ): Promise<string>;
96
+ displayNotification(notification: Record<string, unknown>): Promise<string>;
97
+ getDisplayedNotifications(): Promise<Record<string, unknown>[]>;
98
+ cancelNotification(id: string): Promise<void>;
99
+ cancelAllNotifications(): Promise<void>;
100
+ getTriggerNotifications(): Promise<Record<string, unknown>[]>;
101
+ /** Identifiers only. Optional, because not every release of the binding has it. */
102
+ getTriggerNotificationIds?(): Promise<string[]>;
103
+ onBackgroundEvent(handler: (event: Record<string, unknown>) => Promise<void>): void;
104
+ requestPermission(options?: Record<string, unknown>): Promise<Record<string, unknown>>;
105
+ getNotificationSettings(): Promise<Record<string, unknown>>;
106
+ openNotificationSettings(): Promise<void>;
107
+ openAlarmPermissionSettings(): Promise<void>;
108
+ isBatteryOptimizationEnabled(): Promise<boolean>;
109
+ onForegroundEvent(handler: (event: Record<string, unknown>) => void): () => void;
110
+ getInitialNotification(): Promise<Record<string, unknown> | null>;
111
+ }
112
+
113
+ /**
114
+ * Drops keys whose value is `undefined`.
115
+ *
116
+ * The engine validates its arguments by key presence, not by value: passing
117
+ * `{ groupId: undefined }` fails with "expected a string value", while omitting
118
+ * the key entirely is accepted. JavaScript treats the two as interchangeable
119
+ * almost everywhere, which is exactly why this is easy to get wrong — and why
120
+ * it only surfaces on a real device, where the native validator runs.
121
+ */
122
+ export function compact<T extends Record<string, unknown>>(value: T): Partial<T> {
123
+ const output: Record<string, unknown> = {};
124
+ for (const [key, entry] of Object.entries(value)) {
125
+ if (entry !== undefined) output[key] = entry;
126
+ }
127
+ return output as Partial<T>;
128
+ }
129
+
130
+ const notifyKit = lazyPeer('react-native-notify-kit', () => {
131
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
132
+ try {
133
+ return require('react-native-notify-kit') as { default: NotifyKitModule };
134
+ } catch (cause) {
135
+ throw cause;
136
+ }
137
+ });
138
+
139
+ export function loadEngine(): NotifyKitModule {
140
+ return notifyKit.require(
141
+ (_peer, cause) =>
142
+ new NotificationError(
143
+ DIAGNOSTIC_CODES.PUSH_UNAVAILABLE,
144
+ 'react-native-notify-kit is not installed or its native module is not linked. Install it as a peer dependency and rebuild the app.',
145
+ { reason: cause instanceof Error ? cause.name : 'unknown' },
146
+ ),
147
+ ).default;
148
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Android: channels, exact alarms, battery optimisation, looping alerts.
3
+ */
4
+ import type { RestrictionReport } from '../../../contract/permission';
5
+ import { toPermissionState } from '../events';
6
+ import { ANDROID_IMPORTANCE, BACKGROUND_WINDOW_MS, MARKER_CHANNEL_ID, compact } from '../module';
7
+ import type { NotifyKitPlatform, PlatformContext } from './types';
8
+
9
+ /** Android has no hard pending ceiling like iOS; this bound keeps behavior predictable. */
10
+ const ANDROID_PENDING_BUDGET = 500;
11
+ /** Android 13: the first version with a runtime display permission. */
12
+ const DISPLAY_PERMISSION_API = 33;
13
+ /** Android 12: the first version that gates exact alarms. */
14
+ const EXACT_ALARM_API = 31;
15
+
16
+ type DeclaredChannel = { id: string; name?: string; description?: string };
17
+
18
+ export function createAndroidPlatform({ engine, apiLevel }: PlatformContext): NotifyKitPlatform {
19
+ let markerChannelReady = false;
20
+ /** The declared channels for the duration of one `withChannelSnapshot` (NOT-08). */
21
+ let channelSnapshot: DeclaredChannel[] | undefined;
22
+ const declaredChannels = async (): Promise<DeclaredChannel[]> =>
23
+ channelSnapshot ?? engine().getChannels();
24
+ // An unknown API level is treated as current: gating is the safe assumption.
25
+ const atLeast = (level: number): boolean => (apiLevel() ?? Number.POSITIVE_INFINITY) >= level;
26
+
27
+ return {
28
+ capabilities: () => ({
29
+ pendingCeiling: ANDROID_PENDING_BUDGET,
30
+ supportsExactAlarms: true,
31
+ supportsChannels: true,
32
+ supportsTextInputActions: true,
33
+ supportsDoNotDisturbBypass: true,
34
+ supportsRepeatingAlert: true,
35
+ backgroundWindowMs: BACKGROUND_WINDOW_MS,
36
+ }),
37
+
38
+ /**
39
+ * Lazily rather than at initialization, so an app that never declares a
40
+ * repeating tone never grows an extra entry in the user's notification
41
+ * settings for a feature it does not use.
42
+ */
43
+ async ensureMarkerChannel() {
44
+ if (markerChannelReady) return;
45
+ await engine().createChannel({
46
+ id: MARKER_CHANNEL_ID,
47
+ name: 'Background maintenance',
48
+ description:
49
+ 'Used briefly to end a repeating alert. Notifications here are removed immediately.',
50
+ importance: ANDROID_IMPORTANCE.min,
51
+ vibration: false,
52
+ badge: false,
53
+ });
54
+ markerChannelReady = true;
55
+ },
56
+
57
+ async withChannelSnapshot(work) {
58
+ if (channelSnapshot !== undefined) {
59
+ return work();
60
+ }
61
+ channelSnapshot = await engine().getChannels();
62
+ try {
63
+ return await work();
64
+ } finally {
65
+ channelSnapshot = undefined;
66
+ }
67
+ },
68
+
69
+ async declareTone(tone) {
70
+ const existing = await declaredChannels();
71
+ const held = existing.find(channel => channel.id === tone.android.channelId);
72
+ const remember = () => {
73
+ channelSnapshot = channelSnapshot
74
+ ?.filter(channel => channel.id !== tone.android.channelId)
75
+ .concat({
76
+ id: tone.android.channelId,
77
+ name: tone.android.name,
78
+ description: tone.android.description,
79
+ });
80
+ };
81
+
82
+ const payload = compact({
83
+ id: tone.android.channelId,
84
+ // The consumer's declared label, not the tone identifier. Passing the
85
+ // identifier here is what put a technical string in front of users on
86
+ // the one screen these labels exist to serve.
87
+ name: tone.android.name,
88
+ description: tone.android.description,
89
+ importance: ANDROID_IMPORTANCE[tone.android.importance],
90
+ sound: tone.android.sound,
91
+ vibration: tone.android.vibration,
92
+ badge: tone.android.badge,
93
+ bypassDnd: tone.android.bypassDnd,
94
+ groupId: tone.android.groupId,
95
+ });
96
+
97
+ if (held === undefined) {
98
+ await engine().createChannel(payload);
99
+ remember();
100
+ return 'created';
101
+ }
102
+ if (held.name === tone.android.name && held.description === tone.android.description) {
103
+ // Nothing to say to the platform. Skipping the call is what keeps an
104
+ // unchanged configuration genuinely free, which reconciliation
105
+ // idempotence depends on.
106
+ return 'unchanged';
107
+ }
108
+ // Android accepts a re-declaration of an existing channel and applies only
109
+ // the name and description, ignoring importance, sound, vibration and DND
110
+ // — because those belong to the user once the channel exists. That refusal
111
+ // is the guarantee: the platform declines to overwrite the user's settings.
112
+ await engine().createChannel(payload);
113
+ remember();
114
+ return 'labelsRefreshed';
115
+ },
116
+
117
+ async retireTone(channelId) {
118
+ const existing = await declaredChannels();
119
+ if (!existing.some(channel => channel.id === channelId)) return false;
120
+ await engine().deleteChannel(channelId);
121
+ channelSnapshot = channelSnapshot?.filter(channel => channel.id !== channelId);
122
+ return true;
123
+ },
124
+
125
+ async declareGroups(groups) {
126
+ for (const group of groups) {
127
+ await engine().createChannelGroup(
128
+ compact({ id: group.id, name: group.name, description: group.description }),
129
+ );
130
+ }
131
+ },
132
+
133
+ async listDeclaredChannels() {
134
+ return (await declaredChannels()).map(channel => channel.id);
135
+ },
136
+
137
+ permissionFrom(kind, settings) {
138
+ if (kind === 'display') {
139
+ // Android below 13 has no runtime display permission at all.
140
+ return atLeast(DISPLAY_PERMISSION_API)
141
+ ? toPermissionState(settings.authorizationStatus)
142
+ : 'notRequired';
143
+ }
144
+ // Before Android 12 the concept does not apply: "not required", not "denied".
145
+ if (!atLeast(EXACT_ALARM_API)) {
146
+ return 'notRequired';
147
+ }
148
+ const android = (settings.android ?? {}) as Record<string, unknown>;
149
+ return android.alarm === 1 ? 'granted' : 'denied';
150
+ },
151
+
152
+ gatesExactAlarms: () => atLeast(EXACT_ALARM_API),
153
+
154
+ async restrictions(now) {
155
+ let batteryOptimization: RestrictionReport['batteryOptimization'] = 'unknown';
156
+ try {
157
+ batteryOptimization = (await engine().isBatteryOptimizationEnabled())
158
+ ? 'restricted'
159
+ : 'unrestricted';
160
+ } catch {
161
+ batteryOptimization = 'unknown';
162
+ }
163
+ return {
164
+ batteryOptimization,
165
+ // No standard API exposes vendor auto-start policy, and guessing would
166
+ // be worse than admitting it. `unknown` is a first-class answer here.
167
+ autoStart: 'unknown',
168
+ readAt: now(),
169
+ };
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The one place this package asks which platform it is on (IOS-08).
3
+ *
4
+ * rn-core's `currentPlatform()` decides the OS; the Android API level is read
5
+ * from React Native lazily, when asked, so the module loads without it.
6
+ */
7
+ import { currentPlatform, lazyPeer } from '@chipmobilesdk/rn-core';
8
+
9
+ import type { PlatformInfo } from '../../../permission/state';
10
+ import type { NotifyKitModule } from '../module';
11
+ import { createAndroidPlatform } from './android';
12
+ import { createIosPlatform } from './ios';
13
+ import type { NotifyKitPlatform } from './types';
14
+
15
+ export type { NotifyKitPlatform } from './types';
16
+
17
+ type ReactNativeSlice = { Platform?: { Version?: unknown } };
18
+
19
+ const reactNative = lazyPeer<ReactNativeSlice>('react-native', () => {
20
+ try {
21
+ return require('react-native') as ReactNativeSlice;
22
+ } catch (cause) {
23
+ throw cause;
24
+ }
25
+ });
26
+
27
+ /** The running OS and, on Android, its API level. */
28
+ export function detectPlatform(): PlatformInfo {
29
+ const os = currentPlatform();
30
+ if (os !== 'android') {
31
+ return { os: os === 'ios' ? 'ios' : 'other' };
32
+ }
33
+ const result = reactNative.get();
34
+ const version = result.ok ? result.module.Platform?.Version : undefined;
35
+ return { os, apiLevel: typeof version === 'number' ? version : undefined };
36
+ }
37
+
38
+ /**
39
+ * The strategy for `platform`, or for the running one. Only Android gets the
40
+ * Android strategy; anywhere else nothing channel- or alarm-specific is called.
41
+ */
42
+ export function createNotifyKitPlatform(
43
+ engine: () => NotifyKitModule,
44
+ platform?: PlatformInfo,
45
+ ): NotifyKitPlatform {
46
+ const os = platform?.os ?? detectPlatform().os;
47
+ if (os !== 'android') {
48
+ return createIosPlatform();
49
+ }
50
+ return createAndroidPlatform({
51
+ engine,
52
+ // Read when asked rather than once, so it follows the device (and a test).
53
+ apiLevel: () => (platform ? platform.apiLevel : detectPlatform().apiLevel),
54
+ });
55
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * iOS: no channels, no exact-alarm gate, a hard ceiling of 64 pending
3
+ * notifications. Categories are declared per request rather than up front.
4
+ */
5
+ import { toPermissionState } from '../events';
6
+ import { BACKGROUND_WINDOW_MS } from '../module';
7
+ import type { NotifyKitPlatform } from './types';
8
+
9
+ /**
10
+ * iOS keeps only the 64 soonest pending notifications and silently discards the
11
+ * rest. This number is the entire reason truncation must be computed and
12
+ * reported by the package rather than left to the platform.
13
+ */
14
+ const IOS_PENDING_CEILING = 64;
15
+
16
+ export function createIosPlatform(): NotifyKitPlatform {
17
+ return {
18
+ capabilities: () => ({
19
+ pendingCeiling: IOS_PENDING_CEILING,
20
+ supportsExactAlarms: false,
21
+ supportsChannels: false,
22
+ supportsTextInputActions: true,
23
+ supportsDoNotDisturbBypass: false,
24
+ // iOS exposes no looping-sound API. The one alert level that overrides the
25
+ // mute switch is entitlement-gated by Apple, and does not loop either — so
26
+ // there is nothing to partially support, and a partial emulation would be
27
+ // worse than an honest refusal.
28
+ supportsRepeatingAlert: false,
29
+ backgroundWindowMs: BACKGROUND_WINDOW_MS,
30
+ }),
31
+
32
+ // No marker is ever placed where alerts cannot repeat.
33
+ async ensureMarkerChannel() {},
34
+
35
+ withChannelSnapshot: work => work(),
36
+ // Categories are declared at request time rather than up front.
37
+ declareTone: async () => 'unchanged',
38
+ retireTone: async () => false,
39
+ async declareGroups() {},
40
+ listDeclaredChannels: async () => [],
41
+
42
+ permissionFrom: (kind, settings) =>
43
+ // Exact alarms are an Android concept: "not required", not "denied".
44
+ kind === 'display' ? toPermissionState(settings.authorizationStatus) : 'notRequired',
45
+
46
+ gatesExactAlarms: () => false,
47
+
48
+ restrictions: async now => ({
49
+ batteryOptimization: 'unrestricted',
50
+ autoStart: 'unrestricted',
51
+ readAt: now(),
52
+ }),
53
+ };
54
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What differs between Android and iOS in the notify-kit engine (NOT-18, IOS-08).
3
+ *
4
+ * The engine picks one implementation when it is created and never branches on
5
+ * the OS again. Changing iOS behaviour touches `ios.ts` only, and the Android
6
+ * file that runs in production stays as it is.
7
+ */
8
+ import type { ChannelGroup, ResolvedTone } from '../../../contract/channel';
9
+ import type {
10
+ PermissionKind,
11
+ PermissionState,
12
+ RestrictionReport,
13
+ } from '../../../contract/permission';
14
+ import type { EngineCapabilities, ToneDeclarationOutcome } from '../../adapter';
15
+ import type { NotifyKitModule } from '../module';
16
+
17
+ export interface NotifyKitPlatform {
18
+ capabilities(): EngineCapabilities;
19
+ /** Creates the channel stop markers are posted on, the first time one is needed. */
20
+ ensureMarkerChannel(): Promise<void>;
21
+
22
+ withChannelSnapshot<T>(work: () => Promise<T>): Promise<T>;
23
+ declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome>;
24
+ retireTone(channelId: string): Promise<boolean>;
25
+ declareGroups(groups: readonly ChannelGroup[]): Promise<void>;
26
+ listDeclaredChannels(): Promise<readonly string[]>;
27
+
28
+ /** The state of `kind`, from the engine's settings as just read. */
29
+ permissionFrom(kind: PermissionKind, settings: Record<string, unknown>): PermissionState;
30
+ /** Whether exact alarms are a capability the user grants here. */
31
+ gatesExactAlarms(): boolean;
32
+ restrictions(now: () => number): Promise<RestrictionReport>;
33
+ }
34
+
35
+ export interface PlatformContext {
36
+ /** The engine module, resolved lazily. */
37
+ readonly engine: () => NotifyKitModule;
38
+ /** Android API level, read when asked; undefined elsewhere or when unknown. */
39
+ readonly apiLevel: () => number | undefined;
40
+ }