@chipmobilesdk/rn-notification 0.4.1 → 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 (45) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +108 -12
  3. package/package.json +10 -2
  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 +21 -6
  9. package/src/contract/hash.ts +26 -0
  10. package/src/diagnostics/events.ts +10 -1
  11. package/src/diagnostics/redact.ts +9 -26
  12. package/src/diagnostics/snapshot.ts +34 -1
  13. package/src/engine/adapter.ts +21 -1
  14. package/src/engine/notify-kit/events.ts +155 -0
  15. package/src/engine/notify-kit/module.ts +148 -0
  16. package/src/engine/notify-kit/platform/android.ts +172 -0
  17. package/src/engine/notify-kit/platform/index.ts +55 -0
  18. package/src/engine/notify-kit/platform/ios.ts +54 -0
  19. package/src/engine/notify-kit/platform/types.ts +40 -0
  20. package/src/engine/notify-kit-engine.ts +96 -468
  21. package/src/index.ts +9 -0
  22. package/src/initialize.ts +10 -3
  23. package/src/notifications.ts +195 -0
  24. package/src/permission/index.ts +65 -4
  25. package/src/push/fcm-provider.ts +28 -11
  26. package/src/push/gate.ts +27 -5
  27. package/src/reconcile/diff.ts +38 -27
  28. package/src/reconcile/index.ts +13 -4
  29. package/src/recurrence/index.ts +2 -1
  30. package/src/repeat/expiry.ts +2 -6
  31. package/src/repeat/index.ts +45 -1
  32. package/src/repeat/marker.ts +1 -22
  33. package/src/repeat/place.ts +74 -35
  34. package/src/schedule/anchor.ts +35 -19
  35. package/src/schedule/index.ts +10 -4
  36. package/src/schedule/partition.ts +0 -19
  37. package/src/schedule/validate-identifier.ts +8 -6
  38. package/src/schedule/writer.ts +13 -4
  39. package/src/state/caps.ts +28 -3
  40. package/src/state/interaction-buffer.ts +30 -2
  41. package/src/state/mmkv-store.ts +54 -37
  42. package/src/state/permission-history.ts +25 -0
  43. package/src/state/record.ts +114 -20
  44. package/src/state/store.ts +3 -0
  45. package/src/testing/fake-engine.ts +17 -1
@@ -105,6 +105,12 @@ export interface NotificationEngineAdapter {
105
105
 
106
106
  /** Full detail, so callers can diff rather than merely count. */
107
107
  getPending(): Promise<readonly PendingEntry[]>;
108
+ /**
109
+ * The identifiers of pending entries, without their payloads (NOT-08).
110
+ * Optional: an engine without a cheap way to list them leaves it out and the
111
+ * package reads `getPending()` instead.
112
+ */
113
+ pendingIds?(): Promise<readonly string[]>;
108
114
 
109
115
  /**
110
116
  * Applies a channel or category declaration.
@@ -119,6 +125,12 @@ export interface NotificationEngineAdapter {
119
125
  declareGroups(groups: readonly ChannelGroup[]): Promise<void>;
120
126
  /** Channel ids the platform currently holds, which re-declaration cannot reveal. */
121
127
  listDeclaredChannels(): Promise<readonly string[]>;
128
+ /**
129
+ * Runs `work` with the declared channels read once and kept current as `work`
130
+ * declares and retires them (NOT-08). Optional; without it each declaration
131
+ * reads the channel list itself.
132
+ */
133
+ withChannelSnapshot?<T>(work: () => Promise<T>): Promise<T>;
122
134
 
123
135
  getPermission(kind: PermissionKind): Promise<PermissionState>;
124
136
  requestPermission(kind: PermissionKind): Promise<PermissionState>;
@@ -165,7 +177,15 @@ export interface NotificationEngineAdapter {
165
177
  * a consumer's behalf, which is why it is a documented setup step rather than
166
178
  * an internal detail — and why its absence is reported rather than guessed at.
167
179
  */
168
- registerBackgroundDeliveryHandler(handler: (event: DeliveryEvent) => Promise<void>): void;
180
+ registerBackgroundDeliveryHandler(
181
+ handler: (event: DeliveryEvent) => Promise<void>,
182
+ /**
183
+ * Taps, actions and dismissals that arrive while the app is not in front
184
+ * (0.6.0). The engine has one background handler per process, so interactions
185
+ * travel through this registration too; without it they were dropped.
186
+ */
187
+ onInteraction?: (event: InteractionEvent) => Promise<void>,
188
+ ): void;
169
189
 
170
190
  /** Whether a background delivery handler has been registered in this process. */
171
191
  hasBackgroundDeliveryHandler(): boolean;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Reading what the engine hands back: pending entries, interactions, deliveries
3
+ * and authorization values, turned into the package's own shapes.
4
+ *
5
+ * Pure functions of the raw value and a clock, so they are testable without an
6
+ * engine and shared by the foreground, background and cold-start paths.
7
+ */
8
+ import type { PendingEntry } from '../../contract/notification';
9
+ import type { PermissionState } from '../../contract/permission';
10
+ import type { InteractionEvent } from '../../contract/interaction';
11
+ import { isRepeatStopId } from '../../repeat/marker';
12
+ import type { DeliveryEvent, RepeatStop } from '../adapter';
13
+ import { unpackData } from '../codec';
14
+ import {
15
+ EVENT_TYPE_ACTION_PRESS,
16
+ EVENT_TYPE_DELIVERED,
17
+ EVENT_TYPE_DISMISSED,
18
+ EVENT_TYPE_PRESS,
19
+ } from './module';
20
+
21
+ type Raw = Record<string, unknown>;
22
+
23
+ const bag = (value: unknown): Raw => (value ?? {}) as Raw;
24
+ const dataOf = (notification: Raw): Record<string, string> =>
25
+ (notification.data ?? {}) as Record<string, string>;
26
+
27
+ /** Reads a stop marker back out of a pending entry's data bag, if it is one. */
28
+ export function unpackRepeatStop(id: string, data: Record<string, string>): RepeatStop | undefined {
29
+ if (!isRepeatStopId(id)) return undefined;
30
+ const targetId = data.__cms_stop_target;
31
+ const dueAt = Number.parseInt(data.__cms_stop_due ?? '', 10);
32
+ if (typeof targetId !== 'string' || !Number.isFinite(dueAt)) return undefined;
33
+ return { markerId: id, targetId, dueAt };
34
+ }
35
+
36
+ export function unpackEntry(raw: Raw, now: () => number): PendingEntry | undefined {
37
+ const notification = bag(raw.notification);
38
+ const trigger = bag(raw.trigger);
39
+ const id = notification.id;
40
+ if (typeof id !== 'string') return undefined;
41
+
42
+ return {
43
+ id,
44
+ resolvedAt: typeof trigger.timestamp === 'number' ? trigger.timestamp : now(),
45
+ content: {
46
+ title: typeof notification.title === 'string' ? notification.title : '',
47
+ body: typeof notification.body === 'string' ? notification.body : '',
48
+ subtitle: typeof notification.subtitle === 'string' ? notification.subtitle : undefined,
49
+ },
50
+ ...unpackData(dataOf(notification)),
51
+ };
52
+ }
53
+
54
+ /** Every stop marker among the engine's pending triggers. */
55
+ export function unpackRepeatStops(raw: readonly Raw[]): RepeatStop[] {
56
+ const stops: RepeatStop[] = [];
57
+ for (const entry of raw) {
58
+ const notification = bag(entry.notification);
59
+ const id = notification.id;
60
+ if (typeof id !== 'string') continue;
61
+ const stop = unpackRepeatStop(id, dataOf(notification));
62
+ if (stop !== undefined) stops.push(stop);
63
+ }
64
+ return stops;
65
+ }
66
+
67
+ /**
68
+ * An interaction, or undefined for anything that is not one.
69
+ *
70
+ * Only a dismissal or a press is an interaction. Everything else the engine
71
+ * emits on this channel — delivery, trigger creation, channel blocking — is not,
72
+ * and reporting it as one would cancel a notification the user never touched.
73
+ */
74
+ export function toInteraction(raw: Raw, now: () => number): InteractionEvent | undefined {
75
+ const eventType = raw.type;
76
+ if (
77
+ eventType !== EVENT_TYPE_DISMISSED &&
78
+ eventType !== EVENT_TYPE_PRESS &&
79
+ eventType !== EVENT_TYPE_ACTION_PRESS
80
+ ) {
81
+ return undefined;
82
+ }
83
+
84
+ const detail = bag(raw.detail);
85
+ const notification = bag(detail.notification);
86
+ const pressAction = bag(detail.pressAction);
87
+ const id = notification.id;
88
+ if (typeof id !== 'string') return undefined;
89
+
90
+ const data = dataOf(notification);
91
+ const actionId = typeof pressAction.id === 'string' ? pressAction.id : undefined;
92
+ // `default` is the engine's own press-action id for tapping the body, so it is
93
+ // a tap rather than a named action.
94
+ const isNamedAction = actionId !== undefined && actionId !== 'default';
95
+ let kind: InteractionEvent['kind'];
96
+ if (eventType === EVENT_TYPE_DISMISSED) {
97
+ kind = 'dismiss';
98
+ } else if (isNamedAction) {
99
+ kind = 'action';
100
+ } else {
101
+ kind = 'tap';
102
+ }
103
+
104
+ return {
105
+ kind,
106
+ notificationId: id,
107
+ actionId: isNamedAction ? actionId : undefined,
108
+ inputText: typeof detail.input === 'string' ? detail.input : undefined,
109
+ // The one decoder for the data bag, so routing is parsed the same way here
110
+ // and when reading the pending set back (NOT-18).
111
+ routing: unpackData(data).routing,
112
+ ownerTag: data.__cms_owner,
113
+ occurredAt: now(),
114
+ appState: 'foreground',
115
+ wasBuffered: false,
116
+ };
117
+ }
118
+
119
+ /** A delivery, or undefined for anything that is not one. */
120
+ export function toDelivery(raw: Raw, now: () => number): DeliveryEvent | undefined {
121
+ if (raw.type !== EVENT_TYPE_DELIVERED) return undefined;
122
+
123
+ const notification = bag(bag(raw.detail).notification);
124
+ const id = notification.id;
125
+ if (typeof id !== 'string') return undefined;
126
+
127
+ return {
128
+ notificationId: id,
129
+ occurredAt: now(),
130
+ repeatStop: unpackRepeatStop(id, dataOf(notification)),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Maps engine authorization values onto our six-state model.
136
+ *
137
+ * `notAsked` and `denied` must stay distinguishable — being able to read state
138
+ * without prompting is worthless if the two collapse. `provisional` is its own
139
+ * state on both edges, never folded into granted or denied.
140
+ */
141
+ export function toPermissionState(raw: unknown): PermissionState {
142
+ switch (raw) {
143
+ case 1:
144
+ case 'authorized':
145
+ return 'granted';
146
+ case 2:
147
+ case 'provisional':
148
+ return 'provisional';
149
+ case 0:
150
+ case 'denied':
151
+ return 'denied';
152
+ default:
153
+ return 'notAsked';
154
+ }
155
+ }
@@ -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
+ }