react-native-permission-gate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +225 -0
- package/lib/module/adapters/reactNativePermissions.js +97 -0
- package/lib/module/adapters/reactNativePermissions.js.map +1 -0
- package/lib/module/components/PermissionGate.js +60 -0
- package/lib/module/components/PermissionGate.js.map +1 -0
- package/lib/module/context.js +29 -0
- package/lib/module/context.js.map +1 -0
- package/lib/module/core/machine.js +107 -0
- package/lib/module/core/machine.js.map +1 -0
- package/lib/module/hooks/usePermission.js +162 -0
- package/lib/module/hooks/usePermission.js.map +1 -0
- package/lib/module/hooks/usePermissions.js +165 -0
- package/lib/module/hooks/usePermissions.js.map +1 -0
- package/lib/module/imperative.js +24 -0
- package/lib/module/imperative.js.map +1 -0
- package/lib/module/index.js +15 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/adapters/reactNativePermissions.d.ts +8 -0
- package/lib/typescript/src/adapters/reactNativePermissions.d.ts.map +1 -0
- package/lib/typescript/src/components/PermissionGate.d.ts +34 -0
- package/lib/typescript/src/components/PermissionGate.d.ts.map +1 -0
- package/lib/typescript/src/context.d.ts +22 -0
- package/lib/typescript/src/context.d.ts.map +1 -0
- package/lib/typescript/src/core/machine.d.ts +47 -0
- package/lib/typescript/src/core/machine.d.ts.map +1 -0
- package/lib/typescript/src/hooks/usePermission.d.ts +8 -0
- package/lib/typescript/src/hooks/usePermission.d.ts.map +1 -0
- package/lib/typescript/src/hooks/usePermissions.d.ts +32 -0
- package/lib/typescript/src/hooks/usePermissions.d.ts.map +1 -0
- package/lib/typescript/src/imperative.d.ts +13 -0
- package/lib/typescript/src/imperative.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +89 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +145 -0
- package/src/adapters/reactNativePermissions.ts +119 -0
- package/src/components/PermissionGate.tsx +81 -0
- package/src/context.tsx +43 -0
- package/src/core/machine.ts +98 -0
- package/src/hooks/usePermission.ts +205 -0
- package/src/hooks/usePermissions.ts +241 -0
- package/src/imperative.ts +35 -0
- package/src/index.tsx +42 -0
- package/src/types.ts +114 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
10
|
+
import { reduce, type MachineEvent, type MachineState } from '../core/machine';
|
|
11
|
+
import { PermissionContext } from '../context';
|
|
12
|
+
import type {
|
|
13
|
+
PermissionController,
|
|
14
|
+
PermissionName,
|
|
15
|
+
PermissionStatus,
|
|
16
|
+
UsePermissionOptions,
|
|
17
|
+
} from '../types';
|
|
18
|
+
|
|
19
|
+
interface Pending {
|
|
20
|
+
promise: Promise<PermissionStatus>;
|
|
21
|
+
resolve: (status: PermissionStatus) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Headless permission-flow controller. Owns the state machine and wires its
|
|
26
|
+
* effects to the native adapter; renders nothing. Bring your own rationale and
|
|
27
|
+
* "blocked" UI and drive it off `phase` / `status`.
|
|
28
|
+
*/
|
|
29
|
+
export function usePermission(
|
|
30
|
+
name: PermissionName,
|
|
31
|
+
options: UsePermissionOptions = {}
|
|
32
|
+
): PermissionController {
|
|
33
|
+
const ctx = useContext(PermissionContext);
|
|
34
|
+
|
|
35
|
+
const rationale = options.rationale ?? ctx.defaults.rationale ?? false;
|
|
36
|
+
const revalidateOnFocus =
|
|
37
|
+
options.revalidateOnFocus ?? ctx.defaults.revalidateOnFocus ?? true;
|
|
38
|
+
const requestOnMount =
|
|
39
|
+
options.requestOnMount ?? ctx.defaults.requestOnMount ?? false;
|
|
40
|
+
const onStatusChange = options.onStatusChange;
|
|
41
|
+
const adapter = options.adapter ?? ctx.adapter;
|
|
42
|
+
|
|
43
|
+
const supported = adapter.isSupported(name);
|
|
44
|
+
|
|
45
|
+
const neutral = (): MachineState => ({
|
|
46
|
+
status: supported ? 'requestable' : 'unavailable',
|
|
47
|
+
phase: 'idle',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const [state, setState] = useState<MachineState>(neutral);
|
|
51
|
+
|
|
52
|
+
// Mirror volatile values in refs so async effect callbacks read fresh data
|
|
53
|
+
// and the public callbacks stay referentially stable.
|
|
54
|
+
const stateRef = useRef(state);
|
|
55
|
+
stateRef.current = state;
|
|
56
|
+
const rationaleRef = useRef(rationale);
|
|
57
|
+
rationaleRef.current = rationale;
|
|
58
|
+
const onStatusChangeRef = useRef(onStatusChange);
|
|
59
|
+
onStatusChangeRef.current = onStatusChange;
|
|
60
|
+
const requestOnMountRef = useRef(requestOnMount);
|
|
61
|
+
requestOnMountRef.current = requestOnMount;
|
|
62
|
+
|
|
63
|
+
// Single in-flight request. Dedupes concurrent/double `request()` calls so a
|
|
64
|
+
// double-tap can never orphan a promise or fire two native prompts.
|
|
65
|
+
const pendingRef = useRef<Pending | null>(null);
|
|
66
|
+
const mountedRef = useRef(true);
|
|
67
|
+
|
|
68
|
+
const dispatch = useCallback(
|
|
69
|
+
(event: MachineEvent) => {
|
|
70
|
+
const { state: next, effects } = reduce(stateRef.current, event, {
|
|
71
|
+
rationale: rationaleRef.current,
|
|
72
|
+
});
|
|
73
|
+
stateRef.current = next;
|
|
74
|
+
if (mountedRef.current) setState(next);
|
|
75
|
+
|
|
76
|
+
for (const effect of effects) {
|
|
77
|
+
if (effect.type === 'resolve') {
|
|
78
|
+
const pending = pendingRef.current;
|
|
79
|
+
pendingRef.current = null;
|
|
80
|
+
pending?.resolve(effect.status);
|
|
81
|
+
} else if (effect.type === 'runRequest') {
|
|
82
|
+
adapter
|
|
83
|
+
.request(name)
|
|
84
|
+
.then((status) =>
|
|
85
|
+
dispatchRef.current({ type: 'REQUEST_RESULT', status })
|
|
86
|
+
)
|
|
87
|
+
.catch(() =>
|
|
88
|
+
dispatchRef.current({
|
|
89
|
+
type: 'REQUEST_RESULT',
|
|
90
|
+
status: stateRef.current.status,
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[adapter, name]
|
|
97
|
+
);
|
|
98
|
+
const dispatchRef = useRef(dispatch);
|
|
99
|
+
dispatchRef.current = dispatch;
|
|
100
|
+
|
|
101
|
+
const request = useCallback(() => {
|
|
102
|
+
// Reuse the in-flight promise instead of starting a second flow.
|
|
103
|
+
if (pendingRef.current) return pendingRef.current.promise;
|
|
104
|
+
let resolve!: (status: PermissionStatus) => void;
|
|
105
|
+
const promise = new Promise<PermissionStatus>((r) => {
|
|
106
|
+
resolve = r;
|
|
107
|
+
});
|
|
108
|
+
pendingRef.current = { promise, resolve };
|
|
109
|
+
dispatchRef.current({ type: 'REQUEST' });
|
|
110
|
+
return promise;
|
|
111
|
+
}, []);
|
|
112
|
+
const requestRef = useRef(request);
|
|
113
|
+
requestRef.current = request;
|
|
114
|
+
|
|
115
|
+
const confirmRationale = useCallback(
|
|
116
|
+
() => dispatchRef.current({ type: 'CONFIRM_RATIONALE' }),
|
|
117
|
+
[]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const cancelRationale = useCallback(
|
|
121
|
+
() => dispatchRef.current({ type: 'CANCEL_RATIONALE' }),
|
|
122
|
+
[]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const refresh = useCallback(async () => {
|
|
126
|
+
if (!supported) return 'unavailable' as PermissionStatus;
|
|
127
|
+
const status = await adapter.check(name);
|
|
128
|
+
dispatchRef.current({ type: 'REFRESH_RESULT', status });
|
|
129
|
+
return status;
|
|
130
|
+
}, [adapter, name, supported]);
|
|
131
|
+
const refreshRef = useRef(refresh);
|
|
132
|
+
refreshRef.current = refresh;
|
|
133
|
+
|
|
134
|
+
const openSettings = useCallback(() => adapter.openSettings(), [adapter]);
|
|
135
|
+
|
|
136
|
+
// Initial check on mount and whenever the permission identity changes. On a
|
|
137
|
+
// change we reset to a neutral state first so a stale status never lingers.
|
|
138
|
+
const firstRunRef = useRef(true);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
mountedRef.current = true;
|
|
141
|
+
if (!firstRunRef.current) {
|
|
142
|
+
const reset = neutral();
|
|
143
|
+
stateRef.current = reset;
|
|
144
|
+
setState(reset);
|
|
145
|
+
pendingRef.current = null;
|
|
146
|
+
}
|
|
147
|
+
firstRunRef.current = false;
|
|
148
|
+
|
|
149
|
+
if (supported) {
|
|
150
|
+
adapter
|
|
151
|
+
.check(name)
|
|
152
|
+
.then((status) => {
|
|
153
|
+
dispatchRef.current({ type: 'CHECK_RESULT', status });
|
|
154
|
+
if (requestOnMountRef.current) requestRef.current();
|
|
155
|
+
})
|
|
156
|
+
.catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
return () => {
|
|
159
|
+
mountedRef.current = false;
|
|
160
|
+
};
|
|
161
|
+
// `neutral` is derived from supported; requestOnMount is read via ref.
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, [adapter, name, supported]);
|
|
164
|
+
|
|
165
|
+
// Re-check when the app returns to the foreground.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!revalidateOnFocus || !supported) return;
|
|
168
|
+
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
|
|
169
|
+
if (next === 'active') refreshRef.current();
|
|
170
|
+
});
|
|
171
|
+
return () => sub.remove();
|
|
172
|
+
}, [revalidateOnFocus, supported]);
|
|
173
|
+
|
|
174
|
+
// Notify on resolved-status changes.
|
|
175
|
+
const prevStatusRef = useRef(state.status);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (prevStatusRef.current !== state.status) {
|
|
178
|
+
prevStatusRef.current = state.status;
|
|
179
|
+
onStatusChangeRef.current?.(state.status);
|
|
180
|
+
}
|
|
181
|
+
}, [state.status]);
|
|
182
|
+
|
|
183
|
+
return useMemo<PermissionController>(
|
|
184
|
+
() => ({
|
|
185
|
+
status: state.status,
|
|
186
|
+
phase: state.phase,
|
|
187
|
+
isGranted: state.status === 'granted',
|
|
188
|
+
isBlocked: state.status === 'blocked',
|
|
189
|
+
request,
|
|
190
|
+
confirmRationale,
|
|
191
|
+
cancelRationale,
|
|
192
|
+
openSettings,
|
|
193
|
+
refresh,
|
|
194
|
+
}),
|
|
195
|
+
[
|
|
196
|
+
state.status,
|
|
197
|
+
state.phase,
|
|
198
|
+
request,
|
|
199
|
+
confirmRationale,
|
|
200
|
+
cancelRationale,
|
|
201
|
+
openSettings,
|
|
202
|
+
refresh,
|
|
203
|
+
]
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
10
|
+
import { PermissionContext } from '../context';
|
|
11
|
+
import type {
|
|
12
|
+
FlowPhase,
|
|
13
|
+
PermissionName,
|
|
14
|
+
PermissionStatus,
|
|
15
|
+
UsePermissionOptions,
|
|
16
|
+
} from '../types';
|
|
17
|
+
|
|
18
|
+
export type PermissionStatusMap = Partial<
|
|
19
|
+
Record<PermissionName, PermissionStatus>
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
export interface PermissionsController {
|
|
23
|
+
/** Current status per requested permission. */
|
|
24
|
+
statuses: PermissionStatusMap;
|
|
25
|
+
/** Shared flow position for the batch. */
|
|
26
|
+
phase: FlowPhase;
|
|
27
|
+
/** Every requested permission is `granted`. */
|
|
28
|
+
allGranted: boolean;
|
|
29
|
+
/** At least one requested permission is `blocked`. */
|
|
30
|
+
anyBlocked: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Run the flow for every not-yet-granted permission behind a single shared
|
|
33
|
+
* rationale gate. Prompts are shown one after another. Resolves with the
|
|
34
|
+
* final status map.
|
|
35
|
+
*/
|
|
36
|
+
request: () => Promise<PermissionStatusMap>;
|
|
37
|
+
/** Proceed from the shared `rationale` phase to the prompts. */
|
|
38
|
+
confirmRationale: () => void;
|
|
39
|
+
/** Abort the shared `rationale` phase. */
|
|
40
|
+
cancelRationale: () => void;
|
|
41
|
+
openSettings: () => Promise<void>;
|
|
42
|
+
refresh: () => Promise<PermissionStatusMap>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Pending {
|
|
46
|
+
promise: Promise<PermissionStatusMap>;
|
|
47
|
+
resolve: (map: PermissionStatusMap) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Aggregate variant of {@link usePermission} for permissions acquired together
|
|
52
|
+
* (e.g. camera + microphone for video). One shared rationale gate, one
|
|
53
|
+
* `request()`, one status map. The `names` array is treated as a stable set -
|
|
54
|
+
* pass a constant or memoized array.
|
|
55
|
+
*/
|
|
56
|
+
export function usePermissions(
|
|
57
|
+
names: PermissionName[],
|
|
58
|
+
options: UsePermissionOptions = {}
|
|
59
|
+
): PermissionsController {
|
|
60
|
+
const ctx = useContext(PermissionContext);
|
|
61
|
+
const rationale = options.rationale ?? ctx.defaults.rationale ?? false;
|
|
62
|
+
const revalidateOnFocus =
|
|
63
|
+
options.revalidateOnFocus ?? ctx.defaults.revalidateOnFocus ?? true;
|
|
64
|
+
const requestOnMount =
|
|
65
|
+
options.requestOnMount ?? ctx.defaults.requestOnMount ?? false;
|
|
66
|
+
const adapter = options.adapter ?? ctx.adapter;
|
|
67
|
+
|
|
68
|
+
// Stable identity for the set so effects don't re-run on every new array.
|
|
69
|
+
const key = names.join(',');
|
|
70
|
+
|
|
71
|
+
const buildInitial = useCallback((): PermissionStatusMap => {
|
|
72
|
+
const map: PermissionStatusMap = {};
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
map[name] = adapter.isSupported(name) ? 'requestable' : 'unavailable';
|
|
75
|
+
}
|
|
76
|
+
return map;
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [adapter, key]);
|
|
79
|
+
|
|
80
|
+
const [statuses, setStatuses] = useState<PermissionStatusMap>(buildInitial);
|
|
81
|
+
const [phase, setPhase] = useState<FlowPhase>('idle');
|
|
82
|
+
|
|
83
|
+
const statusesRef = useRef(statuses);
|
|
84
|
+
statusesRef.current = statuses;
|
|
85
|
+
const phaseRef = useRef(phase);
|
|
86
|
+
phaseRef.current = phase;
|
|
87
|
+
const rationaleRef = useRef(rationale);
|
|
88
|
+
rationaleRef.current = rationale;
|
|
89
|
+
const requestOnMountRef = useRef(requestOnMount);
|
|
90
|
+
requestOnMountRef.current = requestOnMount;
|
|
91
|
+
|
|
92
|
+
const pendingRef = useRef<Pending | null>(null);
|
|
93
|
+
const requestableRef = useRef<PermissionName[]>([]);
|
|
94
|
+
const mountedRef = useRef(true);
|
|
95
|
+
|
|
96
|
+
const commit = useCallback((next: PermissionStatusMap) => {
|
|
97
|
+
statusesRef.current = next;
|
|
98
|
+
if (mountedRef.current) setStatuses(next);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const settle = useCallback(() => {
|
|
102
|
+
if (mountedRef.current) setPhase('settled');
|
|
103
|
+
const pending = pendingRef.current;
|
|
104
|
+
pendingRef.current = null;
|
|
105
|
+
pending?.resolve(statusesRef.current);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const runBatch = useCallback(
|
|
109
|
+
async (list: PermissionName[]) => {
|
|
110
|
+
if (mountedRef.current) setPhase('requesting');
|
|
111
|
+
for (const name of list) {
|
|
112
|
+
try {
|
|
113
|
+
const status = await adapter.request(name);
|
|
114
|
+
commit({ ...statusesRef.current, [name]: status });
|
|
115
|
+
} catch {
|
|
116
|
+
// Keep the previous status for this permission on failure.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
settle();
|
|
120
|
+
},
|
|
121
|
+
[adapter, commit, settle]
|
|
122
|
+
);
|
|
123
|
+
const runBatchRef = useRef(runBatch);
|
|
124
|
+
runBatchRef.current = runBatch;
|
|
125
|
+
|
|
126
|
+
const request = useCallback(() => {
|
|
127
|
+
if (pendingRef.current) return pendingRef.current.promise;
|
|
128
|
+
|
|
129
|
+
let resolve!: (map: PermissionStatusMap) => void;
|
|
130
|
+
const promise = new Promise<PermissionStatusMap>((r) => {
|
|
131
|
+
resolve = r;
|
|
132
|
+
});
|
|
133
|
+
pendingRef.current = { promise, resolve };
|
|
134
|
+
|
|
135
|
+
const requestable = names.filter(
|
|
136
|
+
(name) => statusesRef.current[name] === 'requestable'
|
|
137
|
+
);
|
|
138
|
+
requestableRef.current = requestable;
|
|
139
|
+
|
|
140
|
+
if (requestable.length === 0) {
|
|
141
|
+
settle();
|
|
142
|
+
} else if (rationaleRef.current) {
|
|
143
|
+
if (mountedRef.current) setPhase('rationale');
|
|
144
|
+
} else {
|
|
145
|
+
runBatchRef.current(requestable);
|
|
146
|
+
}
|
|
147
|
+
return promise;
|
|
148
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
149
|
+
}, [key, settle]);
|
|
150
|
+
const requestRef = useRef(request);
|
|
151
|
+
requestRef.current = request;
|
|
152
|
+
|
|
153
|
+
const confirmRationale = useCallback(() => {
|
|
154
|
+
if (phaseRef.current !== 'rationale') return;
|
|
155
|
+
runBatchRef.current(requestableRef.current);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
const cancelRationale = useCallback(() => {
|
|
159
|
+
if (phaseRef.current !== 'rationale') return;
|
|
160
|
+
if (mountedRef.current) setPhase('idle');
|
|
161
|
+
const pending = pendingRef.current;
|
|
162
|
+
pendingRef.current = null;
|
|
163
|
+
pending?.resolve(statusesRef.current);
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const refresh = useCallback(async () => {
|
|
167
|
+
const entries = await Promise.all(
|
|
168
|
+
names.map(async (name) => {
|
|
169
|
+
if (!adapter.isSupported(name)) {
|
|
170
|
+
return [name, 'unavailable'] as const;
|
|
171
|
+
}
|
|
172
|
+
return [name, await adapter.check(name)] as const;
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
const next: PermissionStatusMap = {};
|
|
176
|
+
for (const [name, status] of entries) next[name] = status;
|
|
177
|
+
commit(next);
|
|
178
|
+
return next;
|
|
179
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
180
|
+
}, [adapter, key, commit]);
|
|
181
|
+
const refreshRef = useRef(refresh);
|
|
182
|
+
refreshRef.current = refresh;
|
|
183
|
+
|
|
184
|
+
const openSettings = useCallback(() => adapter.openSettings(), [adapter]);
|
|
185
|
+
|
|
186
|
+
// Initial check on mount / when the set changes.
|
|
187
|
+
const firstRunRef = useRef(true);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
mountedRef.current = true;
|
|
190
|
+
if (!firstRunRef.current) {
|
|
191
|
+
commit(buildInitial());
|
|
192
|
+
setPhase('idle');
|
|
193
|
+
pendingRef.current = null;
|
|
194
|
+
}
|
|
195
|
+
firstRunRef.current = false;
|
|
196
|
+
|
|
197
|
+
refreshRef.current().then(() => {
|
|
198
|
+
if (requestOnMountRef.current) requestRef.current();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return () => {
|
|
202
|
+
mountedRef.current = false;
|
|
203
|
+
};
|
|
204
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
205
|
+
}, [key]);
|
|
206
|
+
|
|
207
|
+
// Re-check on foreground.
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!revalidateOnFocus) return;
|
|
210
|
+
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
|
|
211
|
+
if (next === 'active') refreshRef.current();
|
|
212
|
+
});
|
|
213
|
+
return () => sub.remove();
|
|
214
|
+
}, [revalidateOnFocus]);
|
|
215
|
+
|
|
216
|
+
return useMemo<PermissionsController>(() => {
|
|
217
|
+
const allGranted = names.every((name) => statuses[name] === 'granted');
|
|
218
|
+
const anyBlocked = names.some((name) => statuses[name] === 'blocked');
|
|
219
|
+
return {
|
|
220
|
+
statuses,
|
|
221
|
+
phase,
|
|
222
|
+
allGranted,
|
|
223
|
+
anyBlocked,
|
|
224
|
+
request,
|
|
225
|
+
confirmRationale,
|
|
226
|
+
cancelRationale,
|
|
227
|
+
openSettings,
|
|
228
|
+
refresh,
|
|
229
|
+
};
|
|
230
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
231
|
+
}, [
|
|
232
|
+
key,
|
|
233
|
+
statuses,
|
|
234
|
+
phase,
|
|
235
|
+
request,
|
|
236
|
+
confirmRationale,
|
|
237
|
+
cancelRationale,
|
|
238
|
+
openSettings,
|
|
239
|
+
refresh,
|
|
240
|
+
]);
|
|
241
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { reactNativePermissionsAdapter } from './adapters/reactNativePermissions';
|
|
2
|
+
import type {
|
|
3
|
+
PermissionAdapter,
|
|
4
|
+
PermissionName,
|
|
5
|
+
PermissionStatus,
|
|
6
|
+
} from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Imperative helpers for use outside React (services, sagas, utilities). These
|
|
10
|
+
* are the raw check/request/openSettings calls with no rationale UX - for the
|
|
11
|
+
* full flow with pre-prompts, use {@link usePermission} or {@link PermissionGate}.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Read current status without prompting. */
|
|
15
|
+
export function checkPermission(
|
|
16
|
+
name: PermissionName,
|
|
17
|
+
adapter: PermissionAdapter = reactNativePermissionsAdapter
|
|
18
|
+
): Promise<PermissionStatus> {
|
|
19
|
+
return adapter.check(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Trigger the native OS prompt and resolve with the resulting status. */
|
|
23
|
+
export function requestPermission(
|
|
24
|
+
name: PermissionName,
|
|
25
|
+
adapter: PermissionAdapter = reactNativePermissionsAdapter
|
|
26
|
+
): Promise<PermissionStatus> {
|
|
27
|
+
return adapter.request(name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Open the OS settings screen for this app. */
|
|
31
|
+
export function openAppSettings(
|
|
32
|
+
adapter: PermissionAdapter = reactNativePermissionsAdapter
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
return adapter.openSettings();
|
|
35
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Hooks - the primary, headless API.
|
|
2
|
+
export { usePermission } from './hooks/usePermission';
|
|
3
|
+
export { usePermissions } from './hooks/usePermissions';
|
|
4
|
+
export type {
|
|
5
|
+
PermissionsController,
|
|
6
|
+
PermissionStatusMap,
|
|
7
|
+
} from './hooks/usePermissions';
|
|
8
|
+
|
|
9
|
+
// Declarative convenience.
|
|
10
|
+
export { PermissionGate } from './components/PermissionGate';
|
|
11
|
+
export type {
|
|
12
|
+
PermissionGateProps,
|
|
13
|
+
RationaleControls,
|
|
14
|
+
BlockedControls,
|
|
15
|
+
} from './components/PermissionGate';
|
|
16
|
+
|
|
17
|
+
// Provider - optional shared adapter + default options.
|
|
18
|
+
export { PermissionProvider, PermissionContext } from './context';
|
|
19
|
+
export type {
|
|
20
|
+
PermissionProviderProps,
|
|
21
|
+
PermissionContextValue,
|
|
22
|
+
PermissionDefaults,
|
|
23
|
+
} from './context';
|
|
24
|
+
|
|
25
|
+
// Imperative helpers for use outside React.
|
|
26
|
+
export {
|
|
27
|
+
checkPermission,
|
|
28
|
+
requestPermission,
|
|
29
|
+
openAppSettings,
|
|
30
|
+
} from './imperative';
|
|
31
|
+
|
|
32
|
+
// Default adapter (override via options.adapter or PermissionProvider).
|
|
33
|
+
export { reactNativePermissionsAdapter } from './adapters/reactNativePermissions';
|
|
34
|
+
|
|
35
|
+
export type {
|
|
36
|
+
PermissionName,
|
|
37
|
+
PermissionStatus,
|
|
38
|
+
FlowPhase,
|
|
39
|
+
PermissionController,
|
|
40
|
+
PermissionAdapter,
|
|
41
|
+
UsePermissionOptions,
|
|
42
|
+
} from './types';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OS-level truth about a permission, normalized across platforms.
|
|
3
|
+
*
|
|
4
|
+
* - `granted` - access is allowed.
|
|
5
|
+
* - `limited` - partial access (iOS: e.g. selected photos / contacts).
|
|
6
|
+
* - `requestable` - not granted yet, but the OS prompt can still be shown.
|
|
7
|
+
* - `blocked` - denied and the OS will no longer prompt; only Settings helps.
|
|
8
|
+
* - `unavailable` - the capability does not exist on this device/build.
|
|
9
|
+
*/
|
|
10
|
+
export type PermissionStatus =
|
|
11
|
+
'granted' | 'limited' | 'requestable' | 'blocked' | 'unavailable';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the request flow currently sits. Independent from {@link PermissionStatus}:
|
|
15
|
+
* `status` is the OS truth, `phase` is the position in the UX state machine.
|
|
16
|
+
*
|
|
17
|
+
* - `idle` - nothing in flight.
|
|
18
|
+
* - `rationale` - pre-prompt is shown, awaiting the user's decision.
|
|
19
|
+
* - `requesting` - the native OS dialog is up.
|
|
20
|
+
* - `settled` - the flow finished; read `status` for the outcome.
|
|
21
|
+
*/
|
|
22
|
+
export type FlowPhase = 'idle' | 'rationale' | 'requesting' | 'settled';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cross-platform permission aliases. Each maps to the correct native permission
|
|
26
|
+
* per platform inside the adapter. Aliases with no meaning on the current
|
|
27
|
+
* platform resolve to `unavailable`.
|
|
28
|
+
*/
|
|
29
|
+
export type PermissionName =
|
|
30
|
+
| 'camera'
|
|
31
|
+
| 'microphone'
|
|
32
|
+
| 'photoLibrary'
|
|
33
|
+
| 'photoLibraryAddOnly'
|
|
34
|
+
| 'mediaLibrary'
|
|
35
|
+
| 'contacts'
|
|
36
|
+
| 'calendar'
|
|
37
|
+
| 'calendarWriteOnly'
|
|
38
|
+
| 'reminders'
|
|
39
|
+
| 'locationWhenInUse'
|
|
40
|
+
| 'locationAlways'
|
|
41
|
+
| 'notifications'
|
|
42
|
+
| 'bluetooth'
|
|
43
|
+
| 'motion'
|
|
44
|
+
| 'speechRecognition'
|
|
45
|
+
| 'appTrackingTransparency'
|
|
46
|
+
| 'bodySensors'
|
|
47
|
+
| 'storageReadAudio'
|
|
48
|
+
| 'storageReadVideo'
|
|
49
|
+
| 'faceId';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Backend that performs the actual native permission calls. The core state
|
|
53
|
+
* machine is adapter-agnostic, which keeps it fully unit-testable without any
|
|
54
|
+
* native module. The default adapter is built on `react-native-permissions`.
|
|
55
|
+
*/
|
|
56
|
+
export interface PermissionAdapter {
|
|
57
|
+
/** Whether the alias is meaningful on the current platform. */
|
|
58
|
+
isSupported(name: PermissionName): boolean;
|
|
59
|
+
/** Read current status without prompting. */
|
|
60
|
+
check(name: PermissionName): Promise<PermissionStatus>;
|
|
61
|
+
/** Trigger the native OS prompt and resolve with the resulting status. */
|
|
62
|
+
request(name: PermissionName): Promise<PermissionStatus>;
|
|
63
|
+
/** Open the OS settings screen for this app. */
|
|
64
|
+
openSettings(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface UsePermissionOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Gate the native prompt behind a `rationale` phase so you can show a
|
|
70
|
+
* pre-prompt explanation first. When `true`, `request()` moves to
|
|
71
|
+
* `phase: 'rationale'` and waits for `confirmRationale()` / `cancelRationale()`.
|
|
72
|
+
* @default false
|
|
73
|
+
*/
|
|
74
|
+
rationale?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Re-check status when the app returns to the foreground - catches changes the
|
|
77
|
+
* user made in the OS Settings screen.
|
|
78
|
+
* @default true
|
|
79
|
+
*/
|
|
80
|
+
revalidateOnFocus?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Run the full request flow automatically on mount.
|
|
83
|
+
* @default false
|
|
84
|
+
*/
|
|
85
|
+
requestOnMount?: boolean;
|
|
86
|
+
/** Called whenever the resolved {@link PermissionStatus} changes. */
|
|
87
|
+
onStatusChange?: (status: PermissionStatus) => void;
|
|
88
|
+
/** Override the backend. Defaults to the `react-native-permissions` adapter. */
|
|
89
|
+
adapter?: PermissionAdapter;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The headless controller returned by {@link usePermission}. */
|
|
93
|
+
export interface PermissionController {
|
|
94
|
+
status: PermissionStatus;
|
|
95
|
+
phase: FlowPhase;
|
|
96
|
+
/** Convenience: `status === 'granted'`. */
|
|
97
|
+
isGranted: boolean;
|
|
98
|
+
/** Convenience: `status === 'blocked'`. */
|
|
99
|
+
isBlocked: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Run the flow: check → (rationale gate) → native prompt → settle. The promise
|
|
102
|
+
* resolves with the final status once the whole flow finishes, so it works
|
|
103
|
+
* both imperatively (`await request()`) and declaratively (render on `phase`).
|
|
104
|
+
*/
|
|
105
|
+
request: () => Promise<PermissionStatus>;
|
|
106
|
+
/** Proceed from the `rationale` phase to the native prompt. */
|
|
107
|
+
confirmRationale: () => void;
|
|
108
|
+
/** Abort the `rationale` phase and return to `idle`. */
|
|
109
|
+
cancelRationale: () => void;
|
|
110
|
+
/** Open the OS settings screen for this app. */
|
|
111
|
+
openSettings: () => Promise<void>;
|
|
112
|
+
/** Re-check status on demand (also happens automatically on focus). */
|
|
113
|
+
refresh: () => Promise<PermissionStatus>;
|
|
114
|
+
}
|