@chipmobilesdk/rn-notification 0.1.2 → 0.2.1
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 +96 -42
- package/README.md +399 -306
- package/package.json +2 -1
- package/skills/sdk-rn-notification/SKILL.md +68 -3
- package/src/channel/migrate.ts +86 -12
- package/src/channel/tone-map.ts +130 -90
- package/src/channel/validate-tone.ts +61 -0
- package/src/contract/channel.ts +188 -89
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +130 -123
- package/src/contract/index.ts +10 -10
- package/src/contract/interaction.ts +62 -62
- package/src/contract/notification.ts +118 -118
- package/src/contract/permission.ts +60 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +123 -123
- package/src/diagnostics/burst.ts +101 -101
- package/src/diagnostics/events.ts +72 -72
- package/src/diagnostics/redact.ts +72 -72
- package/src/diagnostics/snapshot.ts +64 -64
- package/src/engine/adapter.ts +172 -79
- package/src/engine/notify-kit-engine.ts +761 -535
- package/src/index.ts +24 -0
- package/src/initialize.ts +174 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +147 -147
- package/src/maintenance/permission-refresh.ts +41 -41
- package/src/maintenance/reanchor.ts +95 -95
- package/src/maintenance/toggle.ts +60 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +61 -61
- package/src/permission/index.ts +183 -183
- package/src/permission/state.ts +77 -77
- package/src/push/envelope.ts +165 -165
- package/src/push/fcm-provider.ts +146 -146
- package/src/push/gate.ts +190 -190
- package/src/push/instructions.ts +109 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +145 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +14 -1
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +71 -71
- package/src/recurrence/expand.ts +292 -292
- package/src/repeat/expiry.ts +55 -0
- package/src/repeat/index.ts +54 -0
- package/src/repeat/marker.ts +72 -0
- package/src/repeat/place.ts +73 -0
- package/src/schedule/anchor.ts +267 -267
- package/src/schedule/index.ts +184 -173
- package/src/schedule/partition.ts +85 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -114
- package/src/state/caps.ts +71 -71
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +75 -71
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +258 -246
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +84 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +248 -194
- package/src/testing/fake-engine.ts +469 -284
- package/src/testing/fake-provider.ts +129 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/measure.ts +337 -334
package/src/permission/index.ts
CHANGED
|
@@ -1,183 +1,183 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Permission coordination.
|
|
3
|
-
*
|
|
4
|
-
* Two rules shape everything here. Reading state never prompts, so the consumer
|
|
5
|
-
* decides when the user is asked — a prompt at the wrong moment is a permanent
|
|
6
|
-
* denial. And a refusal never fails the caller's business operation: the user's
|
|
7
|
-
* actual work completes, and the lost capability is reported separately.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type {
|
|
11
|
-
PermissionKind,
|
|
12
|
-
PermissionReport,
|
|
13
|
-
PermissionState,
|
|
14
|
-
RestrictionReport,
|
|
15
|
-
} from '../contract/permission';
|
|
16
|
-
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
17
|
-
import type { StateManager } from '../state/record';
|
|
18
|
-
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
19
|
-
import { capabilityFor, normalizeState, type PlatformInfo } from './state';
|
|
20
|
-
import { hasRequested, recordRequested } from '../state/permission-history';
|
|
21
|
-
|
|
22
|
-
export interface PermissionDependencies {
|
|
23
|
-
readonly engine: NotificationEngineAdapter;
|
|
24
|
-
readonly state: StateManager;
|
|
25
|
-
readonly platform: PlatformInfo;
|
|
26
|
-
readonly now: () => number;
|
|
27
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type PermissionChangeHandler = (report: PermissionReport) => void;
|
|
31
|
-
|
|
32
|
-
export function createPermissions(deps: PermissionDependencies) {
|
|
33
|
-
const emit = deps.emit ?? (() => {});
|
|
34
|
-
let listeners: PermissionChangeHandler[] = [];
|
|
35
|
-
let lastReport: PermissionReport | undefined;
|
|
36
|
-
|
|
37
|
-
const read = async (): Promise<PermissionReport> => {
|
|
38
|
-
// Never prompts. This is what lets a consumer decide the moment to ask.
|
|
39
|
-
const [display, exactAlarm] = await Promise.all([
|
|
40
|
-
deps.engine.getPermission('display'),
|
|
41
|
-
deps.engine.getPermission('exactAlarm'),
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
display: normalizeState('display', display, deps.platform),
|
|
46
|
-
exactAlarm: normalizeState('exactAlarm', exactAlarm, deps.platform),
|
|
47
|
-
readAt: deps.now(),
|
|
48
|
-
};
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const notifyIfChanged = (report: PermissionReport): boolean => {
|
|
52
|
-
const changed =
|
|
53
|
-
lastReport === undefined ||
|
|
54
|
-
lastReport.display !== report.display ||
|
|
55
|
-
lastReport.exactAlarm !== report.exactAlarm;
|
|
56
|
-
|
|
57
|
-
lastReport = report;
|
|
58
|
-
if (!changed) return false;
|
|
59
|
-
|
|
60
|
-
for (const listener of listeners) {
|
|
61
|
-
try {
|
|
62
|
-
listener(report);
|
|
63
|
-
} catch {
|
|
64
|
-
// A consumer's handler must never break permission reading.
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return true;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const request = async (kind: PermissionKind, ownerTag?: string): Promise<PermissionState> => {
|
|
71
|
-
const capability = capabilityFor(kind, deps.platform);
|
|
72
|
-
|
|
73
|
-
// Requesting something the platform version does not have is a no-op that
|
|
74
|
-
// shows no dialog, rather than a refusal.
|
|
75
|
-
if (!capability.supported) {
|
|
76
|
-
return 'notRequired';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
recordRequested(deps.state, kind, deps.now(), ownerTag);
|
|
80
|
-
|
|
81
|
-
const raw = await deps.engine.requestPermission(kind);
|
|
82
|
-
const result = normalizeState(kind, raw, deps.platform);
|
|
83
|
-
|
|
84
|
-
if (result === 'denied' || result === 'permanentlyDenied') {
|
|
85
|
-
emit(
|
|
86
|
-
kind === 'display'
|
|
87
|
-
? result === 'permanentlyDenied'
|
|
88
|
-
? DIAGNOSTIC_CODES.DISPLAY_PERMISSION_PERMANENT
|
|
89
|
-
: DIAGNOSTIC_CODES.DISPLAY_PERMISSION_DENIED
|
|
90
|
-
: DIAGNOSTIC_CODES.EXACT_ALARM_UNAVAILABLE,
|
|
91
|
-
kind === 'display'
|
|
92
|
-
? 'Display permission was refused. The caller\'s operation still succeeded; notifications will not be shown until this is granted.'
|
|
93
|
-
: 'Exact-alarm capability is unavailable. Notifications are still scheduled, in approximate mode, and may be delivered late.',
|
|
94
|
-
{ kind, state: result },
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
notifyIfChanged(await read());
|
|
99
|
-
return result;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
/** Reads every permission state without triggering any dialog. */
|
|
104
|
-
async getPermissions(): Promise<PermissionReport> {
|
|
105
|
-
const report = await read();
|
|
106
|
-
notifyIfChanged(report);
|
|
107
|
-
return report;
|
|
108
|
-
},
|
|
109
|
-
|
|
110
|
-
/** Requested independently — an app may need one and not the other. */
|
|
111
|
-
requestDisplayPermission(ownerTag?: string): Promise<PermissionState> {
|
|
112
|
-
return request('display', ownerTag);
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
requestExactAlarmPermission(ownerTag?: string): Promise<PermissionState> {
|
|
116
|
-
return request('exactAlarm', ownerTag);
|
|
117
|
-
},
|
|
118
|
-
|
|
119
|
-
/** Whether the package has ever asked — the platform cannot answer this. */
|
|
120
|
-
hasEverRequested(kind: PermissionKind, ownerTag?: string): boolean {
|
|
121
|
-
return hasRequested(deps.state, kind, ownerTag);
|
|
122
|
-
},
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Opens the settings screen for one specific permission.
|
|
126
|
-
*
|
|
127
|
-
* Telling a user to "find it in settings" is how you lose them, so a
|
|
128
|
-
* platform with no reachable screen is an error rather than a silent no-op.
|
|
129
|
-
*/
|
|
130
|
-
async openSettings(kind: PermissionKind): Promise<void> {
|
|
131
|
-
if (!capabilityFor(kind, deps.platform).supported) {
|
|
132
|
-
throw new NotificationError(
|
|
133
|
-
DIAGNOSTIC_CODES.SETTINGS_UNAVAILABLE,
|
|
134
|
-
`No settings screen exists for ${kind} on this platform version.`,
|
|
135
|
-
{ kind, os: deps.platform.os, apiLevel: deps.platform.apiLevel },
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
await deps.engine.openSettings(kind);
|
|
139
|
-
},
|
|
140
|
-
|
|
141
|
-
onPermissionChange(handler: PermissionChangeHandler): () => void {
|
|
142
|
-
listeners.push(handler);
|
|
143
|
-
return () => {
|
|
144
|
-
listeners = listeners.filter(l => l !== handler);
|
|
145
|
-
};
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Manufacturer background restrictions, reported for information only.
|
|
150
|
-
*
|
|
151
|
-
* `unknown` is a first-class answer: some vendors stop apps in ways with no
|
|
152
|
-
* detection API, and guessing would be worse than admitting it. The package
|
|
153
|
-
* changes none of these settings on the user's behalf.
|
|
154
|
-
*/
|
|
155
|
-
async getBackgroundRestrictions(): Promise<RestrictionReport> {
|
|
156
|
-
const report = await deps.engine.getRestrictions();
|
|
157
|
-
|
|
158
|
-
if (report.batteryOptimization === 'restricted' || report.autoStart === 'restricted') {
|
|
159
|
-
emit(
|
|
160
|
-
DIAGNOSTIC_CODES.BACKGROUND_RESTRICTED,
|
|
161
|
-
'The device applies a manufacturer background restriction. Notifications may be delayed or suppressed.',
|
|
162
|
-
{ battery: report.batteryOptimization, autoStart: report.autoStart },
|
|
163
|
-
);
|
|
164
|
-
} else if (report.batteryOptimization === 'unknown' || report.autoStart === 'unknown') {
|
|
165
|
-
emit(
|
|
166
|
-
DIAGNOSTIC_CODES.BACKGROUND_RESTRICTION_UNKNOWN,
|
|
167
|
-
'Manufacturer background restriction could not be determined on this device. Reported as unknown rather than guessed.',
|
|
168
|
-
{ battery: report.batteryOptimization, autoStart: report.autoStart },
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return report;
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
/** Called by the permission-refresh maintenance step. */
|
|
176
|
-
async refresh(): Promise<{ readonly report: PermissionReport; readonly changed: boolean }> {
|
|
177
|
-
const report = await read();
|
|
178
|
-
return { report, changed: notifyIfChanged(report) };
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export type Permissions = ReturnType<typeof createPermissions>;
|
|
1
|
+
/**
|
|
2
|
+
* Permission coordination.
|
|
3
|
+
*
|
|
4
|
+
* Two rules shape everything here. Reading state never prompts, so the consumer
|
|
5
|
+
* decides when the user is asked — a prompt at the wrong moment is a permanent
|
|
6
|
+
* denial. And a refusal never fails the caller's business operation: the user's
|
|
7
|
+
* actual work completes, and the lost capability is reported separately.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
PermissionKind,
|
|
12
|
+
PermissionReport,
|
|
13
|
+
PermissionState,
|
|
14
|
+
RestrictionReport,
|
|
15
|
+
} from '../contract/permission';
|
|
16
|
+
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
17
|
+
import type { StateManager } from '../state/record';
|
|
18
|
+
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
19
|
+
import { capabilityFor, normalizeState, type PlatformInfo } from './state';
|
|
20
|
+
import { hasRequested, recordRequested } from '../state/permission-history';
|
|
21
|
+
|
|
22
|
+
export interface PermissionDependencies {
|
|
23
|
+
readonly engine: NotificationEngineAdapter;
|
|
24
|
+
readonly state: StateManager;
|
|
25
|
+
readonly platform: PlatformInfo;
|
|
26
|
+
readonly now: () => number;
|
|
27
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PermissionChangeHandler = (report: PermissionReport) => void;
|
|
31
|
+
|
|
32
|
+
export function createPermissions(deps: PermissionDependencies) {
|
|
33
|
+
const emit = deps.emit ?? (() => {});
|
|
34
|
+
let listeners: PermissionChangeHandler[] = [];
|
|
35
|
+
let lastReport: PermissionReport | undefined;
|
|
36
|
+
|
|
37
|
+
const read = async (): Promise<PermissionReport> => {
|
|
38
|
+
// Never prompts. This is what lets a consumer decide the moment to ask.
|
|
39
|
+
const [display, exactAlarm] = await Promise.all([
|
|
40
|
+
deps.engine.getPermission('display'),
|
|
41
|
+
deps.engine.getPermission('exactAlarm'),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
display: normalizeState('display', display, deps.platform),
|
|
46
|
+
exactAlarm: normalizeState('exactAlarm', exactAlarm, deps.platform),
|
|
47
|
+
readAt: deps.now(),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const notifyIfChanged = (report: PermissionReport): boolean => {
|
|
52
|
+
const changed =
|
|
53
|
+
lastReport === undefined ||
|
|
54
|
+
lastReport.display !== report.display ||
|
|
55
|
+
lastReport.exactAlarm !== report.exactAlarm;
|
|
56
|
+
|
|
57
|
+
lastReport = report;
|
|
58
|
+
if (!changed) return false;
|
|
59
|
+
|
|
60
|
+
for (const listener of listeners) {
|
|
61
|
+
try {
|
|
62
|
+
listener(report);
|
|
63
|
+
} catch {
|
|
64
|
+
// A consumer's handler must never break permission reading.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const request = async (kind: PermissionKind, ownerTag?: string): Promise<PermissionState> => {
|
|
71
|
+
const capability = capabilityFor(kind, deps.platform);
|
|
72
|
+
|
|
73
|
+
// Requesting something the platform version does not have is a no-op that
|
|
74
|
+
// shows no dialog, rather than a refusal.
|
|
75
|
+
if (!capability.supported) {
|
|
76
|
+
return 'notRequired';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordRequested(deps.state, kind, deps.now(), ownerTag);
|
|
80
|
+
|
|
81
|
+
const raw = await deps.engine.requestPermission(kind);
|
|
82
|
+
const result = normalizeState(kind, raw, deps.platform);
|
|
83
|
+
|
|
84
|
+
if (result === 'denied' || result === 'permanentlyDenied') {
|
|
85
|
+
emit(
|
|
86
|
+
kind === 'display'
|
|
87
|
+
? result === 'permanentlyDenied'
|
|
88
|
+
? DIAGNOSTIC_CODES.DISPLAY_PERMISSION_PERMANENT
|
|
89
|
+
: DIAGNOSTIC_CODES.DISPLAY_PERMISSION_DENIED
|
|
90
|
+
: DIAGNOSTIC_CODES.EXACT_ALARM_UNAVAILABLE,
|
|
91
|
+
kind === 'display'
|
|
92
|
+
? 'Display permission was refused. The caller\'s operation still succeeded; notifications will not be shown until this is granted.'
|
|
93
|
+
: 'Exact-alarm capability is unavailable. Notifications are still scheduled, in approximate mode, and may be delivered late.',
|
|
94
|
+
{ kind, state: result },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
notifyIfChanged(await read());
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
/** Reads every permission state without triggering any dialog. */
|
|
104
|
+
async getPermissions(): Promise<PermissionReport> {
|
|
105
|
+
const report = await read();
|
|
106
|
+
notifyIfChanged(report);
|
|
107
|
+
return report;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** Requested independently — an app may need one and not the other. */
|
|
111
|
+
requestDisplayPermission(ownerTag?: string): Promise<PermissionState> {
|
|
112
|
+
return request('display', ownerTag);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
requestExactAlarmPermission(ownerTag?: string): Promise<PermissionState> {
|
|
116
|
+
return request('exactAlarm', ownerTag);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** Whether the package has ever asked — the platform cannot answer this. */
|
|
120
|
+
hasEverRequested(kind: PermissionKind, ownerTag?: string): boolean {
|
|
121
|
+
return hasRequested(deps.state, kind, ownerTag);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Opens the settings screen for one specific permission.
|
|
126
|
+
*
|
|
127
|
+
* Telling a user to "find it in settings" is how you lose them, so a
|
|
128
|
+
* platform with no reachable screen is an error rather than a silent no-op.
|
|
129
|
+
*/
|
|
130
|
+
async openSettings(kind: PermissionKind): Promise<void> {
|
|
131
|
+
if (!capabilityFor(kind, deps.platform).supported) {
|
|
132
|
+
throw new NotificationError(
|
|
133
|
+
DIAGNOSTIC_CODES.SETTINGS_UNAVAILABLE,
|
|
134
|
+
`No settings screen exists for ${kind} on this platform version.`,
|
|
135
|
+
{ kind, os: deps.platform.os, apiLevel: deps.platform.apiLevel },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
await deps.engine.openSettings(kind);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
onPermissionChange(handler: PermissionChangeHandler): () => void {
|
|
142
|
+
listeners.push(handler);
|
|
143
|
+
return () => {
|
|
144
|
+
listeners = listeners.filter(l => l !== handler);
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Manufacturer background restrictions, reported for information only.
|
|
150
|
+
*
|
|
151
|
+
* `unknown` is a first-class answer: some vendors stop apps in ways with no
|
|
152
|
+
* detection API, and guessing would be worse than admitting it. The package
|
|
153
|
+
* changes none of these settings on the user's behalf.
|
|
154
|
+
*/
|
|
155
|
+
async getBackgroundRestrictions(): Promise<RestrictionReport> {
|
|
156
|
+
const report = await deps.engine.getRestrictions();
|
|
157
|
+
|
|
158
|
+
if (report.batteryOptimization === 'restricted' || report.autoStart === 'restricted') {
|
|
159
|
+
emit(
|
|
160
|
+
DIAGNOSTIC_CODES.BACKGROUND_RESTRICTED,
|
|
161
|
+
'The device applies a manufacturer background restriction. Notifications may be delayed or suppressed.',
|
|
162
|
+
{ battery: report.batteryOptimization, autoStart: report.autoStart },
|
|
163
|
+
);
|
|
164
|
+
} else if (report.batteryOptimization === 'unknown' || report.autoStart === 'unknown') {
|
|
165
|
+
emit(
|
|
166
|
+
DIAGNOSTIC_CODES.BACKGROUND_RESTRICTION_UNKNOWN,
|
|
167
|
+
'Manufacturer background restriction could not be determined on this device. Reported as unknown rather than guessed.',
|
|
168
|
+
{ battery: report.batteryOptimization, autoStart: report.autoStart },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return report;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/** Called by the permission-refresh maintenance step. */
|
|
176
|
+
async refresh(): Promise<{ readonly report: PermissionReport; readonly changed: boolean }> {
|
|
177
|
+
const report = await read();
|
|
178
|
+
return { report, changed: notifyIfChanged(report) };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type Permissions = ReturnType<typeof createPermissions>;
|
package/src/permission/state.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The permission state model.
|
|
3
|
-
*
|
|
4
|
-
* Six states, never collapsed. The two that matter most are the ones a naive
|
|
5
|
-
* model loses: `notAsked` must stay distinguishable from `denied`, or reading
|
|
6
|
-
* state without prompting buys nothing; and `notRequired` must never be
|
|
7
|
-
* reported as `denied`, because a platform version with no such concept never
|
|
8
|
-
* refused anything.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { PermissionCapability, PermissionKind, PermissionState } from '../contract/permission';
|
|
12
|
-
|
|
13
|
-
/** Android runtime notification permission arrived in API 33. */
|
|
14
|
-
export const ANDROID_DISPLAY_PERMISSION_API = 33;
|
|
15
|
-
/** Android exact-alarm permission arrived in API 31. */
|
|
16
|
-
export const ANDROID_EXACT_ALARM_API = 31;
|
|
17
|
-
|
|
18
|
-
export interface PlatformInfo {
|
|
19
|
-
readonly os: 'ios' | 'android' | 'other';
|
|
20
|
-
/** Android API level. Ignored on iOS. */
|
|
21
|
-
readonly apiLevel?: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Whether a permission concept exists at all on this platform version.
|
|
26
|
-
*
|
|
27
|
-
* Returning `supported: false` is what lets `notRequired` be reported honestly
|
|
28
|
-
* rather than inventing a refusal.
|
|
29
|
-
*/
|
|
30
|
-
export function capabilityFor(kind: PermissionKind, platform: PlatformInfo): PermissionCapability {
|
|
31
|
-
if (kind === 'display') {
|
|
32
|
-
if (platform.os === 'android' && (platform.apiLevel ?? 0) < ANDROID_DISPLAY_PERMISSION_API) {
|
|
33
|
-
return { kind, supported: false, settingsOnly: false };
|
|
34
|
-
}
|
|
35
|
-
return { kind, supported: true, settingsOnly: false };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Exact alarms exist only on Android 12+, and are granted through system
|
|
39
|
-
// settings rather than an in-app dialog on the versions that gate them.
|
|
40
|
-
if (platform.os !== 'android' || (platform.apiLevel ?? 0) < ANDROID_EXACT_ALARM_API) {
|
|
41
|
-
return { kind, supported: false, settingsOnly: false };
|
|
42
|
-
}
|
|
43
|
-
return { kind, supported: true, settingsOnly: true };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Normalizes a platform reading into the six-state model. */
|
|
47
|
-
export function normalizeState(
|
|
48
|
-
kind: PermissionKind,
|
|
49
|
-
raw: PermissionState,
|
|
50
|
-
platform: PlatformInfo,
|
|
51
|
-
): PermissionState {
|
|
52
|
-
if (!capabilityFor(kind, platform).supported) {
|
|
53
|
-
return 'notRequired';
|
|
54
|
-
}
|
|
55
|
-
return raw;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Whether a state means the package can still show notifications.
|
|
60
|
-
*
|
|
61
|
-
* `provisional` counts: iOS quiet delivery still reaches the user, just without
|
|
62
|
-
* interrupting them. Treating it as denied would suppress notifications the
|
|
63
|
-
* user is in fact receiving.
|
|
64
|
-
*/
|
|
65
|
-
export function canDisplay(state: PermissionState): boolean {
|
|
66
|
-
return state === 'granted' || state === 'provisional' || state === 'notRequired';
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Whether asking again could produce a dialog, or whether settings is the only route. */
|
|
70
|
-
export function isTerminal(state: PermissionState): boolean {
|
|
71
|
-
return state === 'permanentlyDenied' || state === 'notRequired';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Whether the package has ever asked, which the platform cannot answer after the fact. */
|
|
75
|
-
export function shouldOfferSettings(state: PermissionState): boolean {
|
|
76
|
-
return state === 'denied' || state === 'permanentlyDenied';
|
|
77
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The permission state model.
|
|
3
|
+
*
|
|
4
|
+
* Six states, never collapsed. The two that matter most are the ones a naive
|
|
5
|
+
* model loses: `notAsked` must stay distinguishable from `denied`, or reading
|
|
6
|
+
* state without prompting buys nothing; and `notRequired` must never be
|
|
7
|
+
* reported as `denied`, because a platform version with no such concept never
|
|
8
|
+
* refused anything.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PermissionCapability, PermissionKind, PermissionState } from '../contract/permission';
|
|
12
|
+
|
|
13
|
+
/** Android runtime notification permission arrived in API 33. */
|
|
14
|
+
export const ANDROID_DISPLAY_PERMISSION_API = 33;
|
|
15
|
+
/** Android exact-alarm permission arrived in API 31. */
|
|
16
|
+
export const ANDROID_EXACT_ALARM_API = 31;
|
|
17
|
+
|
|
18
|
+
export interface PlatformInfo {
|
|
19
|
+
readonly os: 'ios' | 'android' | 'other';
|
|
20
|
+
/** Android API level. Ignored on iOS. */
|
|
21
|
+
readonly apiLevel?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a permission concept exists at all on this platform version.
|
|
26
|
+
*
|
|
27
|
+
* Returning `supported: false` is what lets `notRequired` be reported honestly
|
|
28
|
+
* rather than inventing a refusal.
|
|
29
|
+
*/
|
|
30
|
+
export function capabilityFor(kind: PermissionKind, platform: PlatformInfo): PermissionCapability {
|
|
31
|
+
if (kind === 'display') {
|
|
32
|
+
if (platform.os === 'android' && (platform.apiLevel ?? 0) < ANDROID_DISPLAY_PERMISSION_API) {
|
|
33
|
+
return { kind, supported: false, settingsOnly: false };
|
|
34
|
+
}
|
|
35
|
+
return { kind, supported: true, settingsOnly: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Exact alarms exist only on Android 12+, and are granted through system
|
|
39
|
+
// settings rather than an in-app dialog on the versions that gate them.
|
|
40
|
+
if (platform.os !== 'android' || (platform.apiLevel ?? 0) < ANDROID_EXACT_ALARM_API) {
|
|
41
|
+
return { kind, supported: false, settingsOnly: false };
|
|
42
|
+
}
|
|
43
|
+
return { kind, supported: true, settingsOnly: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Normalizes a platform reading into the six-state model. */
|
|
47
|
+
export function normalizeState(
|
|
48
|
+
kind: PermissionKind,
|
|
49
|
+
raw: PermissionState,
|
|
50
|
+
platform: PlatformInfo,
|
|
51
|
+
): PermissionState {
|
|
52
|
+
if (!capabilityFor(kind, platform).supported) {
|
|
53
|
+
return 'notRequired';
|
|
54
|
+
}
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a state means the package can still show notifications.
|
|
60
|
+
*
|
|
61
|
+
* `provisional` counts: iOS quiet delivery still reaches the user, just without
|
|
62
|
+
* interrupting them. Treating it as denied would suppress notifications the
|
|
63
|
+
* user is in fact receiving.
|
|
64
|
+
*/
|
|
65
|
+
export function canDisplay(state: PermissionState): boolean {
|
|
66
|
+
return state === 'granted' || state === 'provisional' || state === 'notRequired';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether asking again could produce a dialog, or whether settings is the only route. */
|
|
70
|
+
export function isTerminal(state: PermissionState): boolean {
|
|
71
|
+
return state === 'permanentlyDenied' || state === 'notRequired';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether the package has ever asked, which the platform cannot answer after the fact. */
|
|
75
|
+
export function shouldOfferSettings(state: PermissionState): boolean {
|
|
76
|
+
return state === 'denied' || state === 'permanentlyDenied';
|
|
77
|
+
}
|