react-native-permission-handler 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.
@@ -0,0 +1,167 @@
1
+ import { createElement } from "react";
2
+ import { type ReactTestRenderer, act, create } from "react-test-renderer";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { MultiplePermissionsConfig, MultiplePermissionsResult } from "../types";
5
+
6
+ vi.mock("react-native", () => ({
7
+ AppState: {
8
+ currentState: "active",
9
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
10
+ },
11
+ }));
12
+
13
+ vi.mock("react-native-permissions", () => ({
14
+ check: vi.fn(),
15
+ request: vi.fn(),
16
+ openSettings: vi.fn(),
17
+ checkNotifications: vi.fn(),
18
+ requestNotifications: vi.fn(),
19
+ }));
20
+
21
+ import { check, request } from "react-native-permissions";
22
+ import { useMultiplePermissions } from "./use-multiple-permissions";
23
+
24
+ function renderHook(hookFn: () => MultiplePermissionsResult) {
25
+ const results: { current: MultiplePermissionsResult } = {} as {
26
+ current: MultiplePermissionsResult;
27
+ };
28
+ function TestComponent() {
29
+ results.current = hookFn();
30
+ return null;
31
+ }
32
+ let renderer: ReactTestRenderer;
33
+ act(() => {
34
+ renderer = create(createElement(TestComponent));
35
+ });
36
+ return {
37
+ result: results,
38
+ unmount: () => act(() => renderer.unmount()),
39
+ };
40
+ }
41
+
42
+ const baseConfig: MultiplePermissionsConfig = {
43
+ permissions: [
44
+ {
45
+ permission:
46
+ "ios.permission.CAMERA" as MultiplePermissionsConfig["permissions"][0]["permission"],
47
+ prePrompt: { title: "Camera", message: "Need camera" },
48
+ blockedPrompt: { title: "Blocked", message: "Camera blocked" },
49
+ },
50
+ {
51
+ permission:
52
+ "ios.permission.MICROPHONE" as MultiplePermissionsConfig["permissions"][0]["permission"],
53
+ prePrompt: { title: "Mic", message: "Need mic" },
54
+ blockedPrompt: { title: "Blocked", message: "Mic blocked" },
55
+ },
56
+ ],
57
+ strategy: "sequential",
58
+ };
59
+
60
+ describe("useMultiplePermissions", () => {
61
+ beforeEach(() => {
62
+ vi.clearAllMocks();
63
+ });
64
+
65
+ it("initializes all permissions as idle", () => {
66
+ const { result } = renderHook(() => useMultiplePermissions(baseConfig));
67
+
68
+ expect(result.current.allGranted).toBe(false);
69
+ expect(Object.values(result.current.statuses)).toEqual(["idle", "idle"]);
70
+ });
71
+
72
+ it("grants all permissions sequentially when already granted", async () => {
73
+ vi.mocked(check).mockResolvedValue("granted");
74
+ const onAllGranted = vi.fn();
75
+
76
+ const { result } = renderHook(() => useMultiplePermissions({ ...baseConfig, onAllGranted }));
77
+
78
+ await act(async () => {
79
+ await result.current.request();
80
+ });
81
+
82
+ expect(result.current.allGranted).toBe(true);
83
+ expect(onAllGranted).toHaveBeenCalledOnce();
84
+ });
85
+
86
+ it("requests denied permissions sequentially", async () => {
87
+ // Initial checks return denied, final re-checks return granted
88
+ vi.mocked(check)
89
+ .mockResolvedValueOnce("denied") // camera initial check
90
+ .mockResolvedValueOnce("denied") // mic initial check
91
+ .mockResolvedValue("granted"); // final re-checks
92
+ vi.mocked(request).mockResolvedValue("granted");
93
+ const onAllGranted = vi.fn();
94
+
95
+ const { result } = renderHook(() => useMultiplePermissions({ ...baseConfig, onAllGranted }));
96
+
97
+ await act(async () => {
98
+ await result.current.request();
99
+ });
100
+
101
+ expect(result.current.allGranted).toBe(true);
102
+ expect(request).toHaveBeenCalledTimes(2);
103
+ expect(onAllGranted).toHaveBeenCalledOnce();
104
+ });
105
+
106
+ it("stops sequential flow when permission is denied", async () => {
107
+ vi.mocked(check).mockResolvedValue("denied");
108
+ vi.mocked(request).mockResolvedValueOnce("denied");
109
+
110
+ const { result } = renderHook(() => useMultiplePermissions(baseConfig));
111
+
112
+ await act(async () => {
113
+ await result.current.request();
114
+ });
115
+
116
+ expect(result.current.allGranted).toBe(false);
117
+ expect(request).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it("fires per-permission callbacks", async () => {
121
+ vi.mocked(check).mockResolvedValue("denied");
122
+ vi.mocked(request).mockResolvedValueOnce("granted").mockResolvedValueOnce("denied");
123
+ const onGrant = vi.fn();
124
+ const onDeny = vi.fn();
125
+
126
+ const config: MultiplePermissionsConfig = {
127
+ ...baseConfig,
128
+ permissions: [
129
+ { ...baseConfig.permissions[0], onGrant },
130
+ { ...baseConfig.permissions[1], onDeny },
131
+ ],
132
+ };
133
+
134
+ const { result } = renderHook(() => useMultiplePermissions(config));
135
+
136
+ await act(async () => {
137
+ await result.current.request();
138
+ });
139
+
140
+ expect(onGrant).toHaveBeenCalledOnce();
141
+ expect(onDeny).toHaveBeenCalledOnce();
142
+ });
143
+
144
+ it("handles parallel strategy — checks all, then requests denied", async () => {
145
+ vi.mocked(check)
146
+ .mockResolvedValueOnce("granted") // camera already granted
147
+ .mockResolvedValueOnce("denied") // mic needs request
148
+ .mockResolvedValue("granted"); // final re-checks
149
+ vi.mocked(request).mockResolvedValue("granted");
150
+ const onAllGranted = vi.fn();
151
+
152
+ const config: MultiplePermissionsConfig = {
153
+ ...baseConfig,
154
+ strategy: "parallel",
155
+ onAllGranted,
156
+ };
157
+
158
+ const { result } = renderHook(() => useMultiplePermissions(config));
159
+
160
+ await act(async () => {
161
+ await result.current.request();
162
+ });
163
+
164
+ expect(request).toHaveBeenCalledTimes(1); // only mic was requested
165
+ expect(onAllGranted).toHaveBeenCalledOnce();
166
+ });
167
+ });
@@ -0,0 +1,196 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import {
3
+ type PermissionStatus,
4
+ check,
5
+ checkNotifications,
6
+ request,
7
+ requestNotifications,
8
+ } from "react-native-permissions";
9
+ import type {
10
+ MultiPermissionEntry,
11
+ MultiplePermissionsConfig,
12
+ MultiplePermissionsResult,
13
+ PermissionFlowState,
14
+ } from "../types";
15
+
16
+ function isNotifications(
17
+ permission: MultiPermissionEntry["permission"],
18
+ ): permission is "notifications" {
19
+ return permission === "notifications";
20
+ }
21
+
22
+ async function checkOne(entry: MultiPermissionEntry): Promise<PermissionStatus> {
23
+ if (isNotifications(entry.permission)) {
24
+ const result = await checkNotifications();
25
+ return result.status;
26
+ }
27
+ return check(entry.permission);
28
+ }
29
+
30
+ async function requestOne(entry: MultiPermissionEntry): Promise<PermissionStatus> {
31
+ if (isNotifications(entry.permission)) {
32
+ const result = await requestNotifications(["alert", "badge", "sound"]);
33
+ return result.status;
34
+ }
35
+ return request(entry.permission);
36
+ }
37
+
38
+ function permissionKey(entry: MultiPermissionEntry): string {
39
+ return String(entry.permission);
40
+ }
41
+
42
+ function isGrantedStatus(status: PermissionStatus): boolean {
43
+ return status === "granted" || status === "limited";
44
+ }
45
+
46
+ export function useMultiplePermissions(
47
+ config: MultiplePermissionsConfig,
48
+ ): MultiplePermissionsResult {
49
+ const { permissions, strategy, onAllGranted } = config;
50
+ const [statuses, setStatuses] = useState<Record<string, PermissionFlowState>>(() => {
51
+ const initial: Record<string, PermissionFlowState> = {};
52
+ for (const entry of permissions) {
53
+ initial[permissionKey(entry)] = "idle";
54
+ }
55
+ return initial;
56
+ });
57
+ const isRunning = useRef(false);
58
+
59
+ const allGranted = permissions.every((entry) => statuses[permissionKey(entry)] === "granted");
60
+
61
+ const requestAll = useCallback(async () => {
62
+ if (isRunning.current) return;
63
+ isRunning.current = true;
64
+
65
+ const update = (key: string, state: PermissionFlowState) => {
66
+ setStatuses((prev) => ({ ...prev, [key]: state }));
67
+ };
68
+
69
+ try {
70
+ if (strategy === "sequential") {
71
+ await runSequential(permissions, update);
72
+ } else {
73
+ await runParallel(permissions, update);
74
+ }
75
+
76
+ // Final check: are all granted?
77
+ let allDone = true;
78
+ for (const entry of permissions) {
79
+ const finalStatus = await checkOne(entry);
80
+ if (!isGrantedStatus(finalStatus)) {
81
+ allDone = false;
82
+ break;
83
+ }
84
+ }
85
+ if (allDone) {
86
+ onAllGranted?.();
87
+ }
88
+ } finally {
89
+ isRunning.current = false;
90
+ }
91
+ }, [permissions, strategy, onAllGranted]);
92
+
93
+ return {
94
+ statuses,
95
+ allGranted,
96
+ request: requestAll,
97
+ };
98
+ }
99
+
100
+ async function runSequential(
101
+ permissions: MultiPermissionEntry[],
102
+ updateStatus: (key: string, state: PermissionFlowState) => void,
103
+ ): Promise<void> {
104
+ for (const entry of permissions) {
105
+ const key = permissionKey(entry);
106
+
107
+ updateStatus(key, "checking");
108
+ const checkStatus = await checkOne(entry);
109
+
110
+ if (isGrantedStatus(checkStatus)) {
111
+ updateStatus(key, "granted");
112
+ entry.onGrant?.();
113
+ continue;
114
+ }
115
+
116
+ if (checkStatus === "unavailable") {
117
+ updateStatus(key, "unavailable");
118
+ continue;
119
+ }
120
+
121
+ if (checkStatus === "blocked") {
122
+ updateStatus(key, "blockedPrompt");
123
+ entry.onBlock?.();
124
+ break;
125
+ }
126
+
127
+ // Denied — request it
128
+ updateStatus(key, "requesting");
129
+ const requestStatus = await requestOne(entry);
130
+
131
+ if (isGrantedStatus(requestStatus)) {
132
+ updateStatus(key, "granted");
133
+ entry.onGrant?.();
134
+ } else if (requestStatus === "blocked") {
135
+ updateStatus(key, "blockedPrompt");
136
+ entry.onBlock?.();
137
+ break;
138
+ } else {
139
+ updateStatus(key, "denied");
140
+ entry.onDeny?.();
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ async function runParallel(
147
+ permissions: MultiPermissionEntry[],
148
+ updateStatus: (key: string, state: PermissionFlowState) => void,
149
+ ): Promise<void> {
150
+ // Check all in parallel
151
+ const checkResults = await Promise.all(
152
+ permissions.map(async (entry) => {
153
+ const key = permissionKey(entry);
154
+ updateStatus(key, "checking");
155
+ const status = await checkOne(entry);
156
+ return { entry, key, status };
157
+ }),
158
+ );
159
+
160
+ // Update granted/unavailable immediately
161
+ for (const { entry, key, status } of checkResults) {
162
+ if (isGrantedStatus(status)) {
163
+ updateStatus(key, "granted");
164
+ entry.onGrant?.();
165
+ } else if (status === "unavailable") {
166
+ updateStatus(key, "unavailable");
167
+ }
168
+ }
169
+
170
+ // Request denied/blocked ones sequentially (system dialogs are sequential)
171
+ const needsAction = checkResults.filter(
172
+ ({ status }) => status === "denied" || status === "blocked",
173
+ );
174
+
175
+ for (const { entry, key, status } of needsAction) {
176
+ if (status === "blocked") {
177
+ updateStatus(key, "blockedPrompt");
178
+ entry.onBlock?.();
179
+ continue;
180
+ }
181
+
182
+ updateStatus(key, "requesting");
183
+ const requestStatus = await requestOne(entry);
184
+
185
+ if (isGrantedStatus(requestStatus)) {
186
+ updateStatus(key, "granted");
187
+ entry.onGrant?.();
188
+ } else if (requestStatus === "blocked") {
189
+ updateStatus(key, "blockedPrompt");
190
+ entry.onBlock?.();
191
+ } else {
192
+ updateStatus(key, "denied");
193
+ entry.onDeny?.();
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,210 @@
1
+ import { createElement } from "react";
2
+ import { type ReactTestRenderer, act, create } from "react-test-renderer";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { PermissionHandlerConfig, PermissionHandlerResult } from "../types";
5
+
6
+ // Mocks must be before imports that use them
7
+ vi.mock("react-native", () => ({
8
+ AppState: {
9
+ currentState: "active",
10
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
11
+ },
12
+ }));
13
+
14
+ vi.mock("react-native-permissions", () => ({
15
+ check: vi.fn(),
16
+ request: vi.fn(),
17
+ openSettings: vi.fn(),
18
+ checkNotifications: vi.fn(),
19
+ requestNotifications: vi.fn(),
20
+ }));
21
+
22
+ import { check, checkNotifications, request, requestNotifications } from "react-native-permissions";
23
+ import { usePermissionHandler } from "./use-permission-handler";
24
+
25
+ // Minimal renderHook using react-test-renderer
26
+ function renderHook(hookFn: () => PermissionHandlerResult) {
27
+ const results: { current: PermissionHandlerResult } = {} as {
28
+ current: PermissionHandlerResult;
29
+ };
30
+ function TestComponent() {
31
+ results.current = hookFn();
32
+ return null;
33
+ }
34
+ let renderer: ReactTestRenderer;
35
+ act(() => {
36
+ renderer = create(createElement(TestComponent));
37
+ });
38
+ return {
39
+ result: results,
40
+ unmount: () => act(() => renderer.unmount()),
41
+ };
42
+ }
43
+
44
+ const baseConfig: PermissionHandlerConfig = {
45
+ permission: "ios.permission.CAMERA" as PermissionHandlerConfig["permission"],
46
+ prePrompt: { title: "Camera", message: "We need camera access" },
47
+ blockedPrompt: { title: "Blocked", message: "Camera is blocked" },
48
+ };
49
+
50
+ describe("usePermissionHandler", () => {
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ });
54
+
55
+ it("auto-checks on mount and transitions to granted", async () => {
56
+ vi.mocked(check).mockResolvedValue("granted");
57
+ const { result } = renderHook(() => usePermissionHandler(baseConfig));
58
+
59
+ // After mount, should be checking
60
+ await act(async () => {});
61
+
62
+ expect(result.current.isGranted).toBe(true);
63
+ expect(result.current.state).toBe("granted");
64
+ expect(check).toHaveBeenCalledWith(baseConfig.permission);
65
+ });
66
+
67
+ it("transitions to prePrompt when permission is denied", async () => {
68
+ vi.mocked(check).mockResolvedValue("denied");
69
+ const { result } = renderHook(() => usePermissionHandler(baseConfig));
70
+
71
+ await act(async () => {});
72
+
73
+ expect(result.current.state).toBe("prePrompt");
74
+ });
75
+
76
+ it("transitions to blockedPrompt when permission is blocked", async () => {
77
+ vi.mocked(check).mockResolvedValue("blocked");
78
+ const { result } = renderHook(() => usePermissionHandler(baseConfig));
79
+
80
+ await act(async () => {});
81
+
82
+ expect(result.current.state).toBe("blockedPrompt");
83
+ expect(result.current.isBlocked).toBe(true);
84
+ });
85
+
86
+ it("transitions to unavailable", async () => {
87
+ vi.mocked(check).mockResolvedValue("unavailable");
88
+ const { result } = renderHook(() => usePermissionHandler(baseConfig));
89
+
90
+ await act(async () => {});
91
+
92
+ expect(result.current.isUnavailable).toBe(true);
93
+ expect(result.current.state).toBe("unavailable");
94
+ });
95
+
96
+ it("skips auto-check when autoCheck is false", async () => {
97
+ const { result } = renderHook(() => usePermissionHandler({ ...baseConfig, autoCheck: false }));
98
+
99
+ await act(async () => {});
100
+
101
+ expect(result.current.state).toBe("idle");
102
+ expect(check).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("requests permission and fires onGrant", async () => {
106
+ vi.mocked(check).mockResolvedValue("denied");
107
+ vi.mocked(request).mockResolvedValue("granted");
108
+ const onGrant = vi.fn();
109
+
110
+ const { result } = renderHook(() => usePermissionHandler({ ...baseConfig, onGrant }));
111
+
112
+ await act(async () => {});
113
+ expect(result.current.state).toBe("prePrompt");
114
+
115
+ await act(async () => {
116
+ result.current.request();
117
+ });
118
+
119
+ expect(result.current.isGranted).toBe(true);
120
+ expect(onGrant).toHaveBeenCalled();
121
+ });
122
+
123
+ it("fires onDeny when request is denied", async () => {
124
+ vi.mocked(check).mockResolvedValue("denied");
125
+ vi.mocked(request).mockResolvedValue("denied");
126
+ const onDeny = vi.fn();
127
+
128
+ const { result } = renderHook(() => usePermissionHandler({ ...baseConfig, onDeny }));
129
+
130
+ await act(async () => {});
131
+ await act(async () => {
132
+ result.current.request();
133
+ });
134
+
135
+ expect(result.current.isDenied).toBe(true);
136
+ expect(onDeny).toHaveBeenCalled();
137
+ });
138
+
139
+ it("fires onBlock when request results in blocked", async () => {
140
+ vi.mocked(check).mockResolvedValue("denied");
141
+ vi.mocked(request).mockResolvedValue("blocked");
142
+ const onBlock = vi.fn();
143
+
144
+ const { result } = renderHook(() => usePermissionHandler({ ...baseConfig, onBlock }));
145
+
146
+ await act(async () => {});
147
+ await act(async () => {
148
+ result.current.request();
149
+ });
150
+
151
+ expect(result.current.state).toBe("blockedPrompt");
152
+ expect(onBlock).toHaveBeenCalled();
153
+ });
154
+
155
+ it("uses notification API when permission is 'notifications'", async () => {
156
+ vi.mocked(checkNotifications).mockResolvedValue({ status: "denied", settings: {} });
157
+ vi.mocked(requestNotifications).mockResolvedValue({ status: "granted", settings: {} });
158
+
159
+ const { result } = renderHook(() =>
160
+ usePermissionHandler({ ...baseConfig, permission: "notifications" }),
161
+ );
162
+
163
+ await act(async () => {});
164
+ expect(checkNotifications).toHaveBeenCalled();
165
+ expect(check).not.toHaveBeenCalled();
166
+
167
+ await act(async () => {
168
+ result.current.request();
169
+ });
170
+
171
+ expect(requestNotifications).toHaveBeenCalledWith(["alert", "badge", "sound"]);
172
+ expect(result.current.isGranted).toBe(true);
173
+ });
174
+
175
+ it("guards against double-tap race condition", async () => {
176
+ vi.mocked(check).mockResolvedValue("denied");
177
+ vi.mocked(request).mockImplementation(
178
+ () => new Promise((resolve) => setTimeout(() => resolve("granted"), 50)),
179
+ );
180
+
181
+ const { result } = renderHook(() => usePermissionHandler(baseConfig));
182
+
183
+ await act(async () => {});
184
+ expect(result.current.state).toBe("prePrompt");
185
+
186
+ await act(async () => {
187
+ result.current.request();
188
+ result.current.request(); // double-tap
189
+ });
190
+
191
+ expect(request).toHaveBeenCalledTimes(1);
192
+ });
193
+
194
+ it("dismiss fires onDeny and transitions to denied", async () => {
195
+ vi.mocked(check).mockResolvedValue("denied");
196
+ const onDeny = vi.fn();
197
+
198
+ const { result } = renderHook(() => usePermissionHandler({ ...baseConfig, onDeny }));
199
+
200
+ await act(async () => {});
201
+ expect(result.current.state).toBe("prePrompt");
202
+
203
+ act(() => {
204
+ result.current.dismiss();
205
+ });
206
+
207
+ expect(result.current.isDenied).toBe(true);
208
+ expect(onDeny).toHaveBeenCalled();
209
+ });
210
+ });