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