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,122 @@
1
+ /**
2
+ * Permission status values owned by this library.
3
+ * Engines must map their native statuses to these values.
4
+ */
5
+ type PermissionStatus = "granted" | "denied" | "blocked" | "limited" | "unavailable";
6
+ /**
7
+ * The pluggable permission engine interface.
8
+ * Implement this to use a custom permissions backend.
9
+ */
10
+ interface PermissionEngine {
11
+ check(permission: string): Promise<PermissionStatus>;
12
+ request(permission: string): Promise<PermissionStatus>;
13
+ openSettings(): Promise<void>;
14
+ }
15
+ /**
16
+ * States of the permission flow state machine.
17
+ */
18
+ type PermissionFlowState = "idle" | "checking" | "prePrompt" | "requesting" | "granted" | "denied" | "blocked" | "blockedPrompt" | "openingSettings" | "recheckingAfterSettings" | "unavailable";
19
+ /**
20
+ * Events that drive state transitions.
21
+ */
22
+ type PermissionFlowEvent = {
23
+ type: "CHECK";
24
+ } | {
25
+ type: "CHECK_RESULT";
26
+ status: PermissionStatus;
27
+ } | {
28
+ type: "PRE_PROMPT_CONFIRM";
29
+ } | {
30
+ type: "PRE_PROMPT_DISMISS";
31
+ } | {
32
+ type: "REQUEST_RESULT";
33
+ status: PermissionStatus;
34
+ } | {
35
+ type: "OPEN_SETTINGS";
36
+ } | {
37
+ type: "SETTINGS_RETURN";
38
+ } | {
39
+ type: "RECHECK_RESULT";
40
+ status: PermissionStatus;
41
+ };
42
+ /**
43
+ * Configuration for the pre-prompt modal.
44
+ */
45
+ interface PrePromptConfig {
46
+ title: string;
47
+ message: string;
48
+ confirmLabel?: string;
49
+ cancelLabel?: string;
50
+ }
51
+ /**
52
+ * Configuration for the blocked-prompt modal.
53
+ */
54
+ interface BlockedPromptConfig {
55
+ title: string;
56
+ message: string;
57
+ settingsLabel?: string;
58
+ }
59
+ /**
60
+ * Callbacks for analytics and side effects.
61
+ */
62
+ interface PermissionCallbacks {
63
+ onGrant?: () => void;
64
+ onDeny?: () => void;
65
+ onBlock?: () => void;
66
+ onSettingsReturn?: (granted: boolean) => void;
67
+ }
68
+ /**
69
+ * Configuration for usePermissionHandler.
70
+ */
71
+ interface PermissionHandlerConfig extends PermissionCallbacks {
72
+ permission: string;
73
+ engine?: PermissionEngine;
74
+ prePrompt: PrePromptConfig;
75
+ blockedPrompt: BlockedPromptConfig;
76
+ autoCheck?: boolean;
77
+ recheckOnForeground?: boolean;
78
+ }
79
+ /**
80
+ * Return type of usePermissionHandler.
81
+ */
82
+ interface PermissionHandlerResult {
83
+ state: PermissionFlowState;
84
+ nativeStatus: PermissionStatus | null;
85
+ isGranted: boolean;
86
+ isDenied: boolean;
87
+ isBlocked: boolean;
88
+ isChecking: boolean;
89
+ isUnavailable: boolean;
90
+ request: () => void;
91
+ check: () => void;
92
+ dismiss: () => void;
93
+ openSettings: () => void;
94
+ }
95
+ /**
96
+ * Configuration for a single permission within useMultiplePermissions.
97
+ */
98
+ interface MultiPermissionEntry extends PermissionCallbacks {
99
+ permission: string;
100
+ prePrompt: PrePromptConfig;
101
+ blockedPrompt: BlockedPromptConfig;
102
+ }
103
+ /**
104
+ * Configuration for useMultiplePermissions.
105
+ */
106
+ interface MultiplePermissionsConfig {
107
+ permissions: MultiPermissionEntry[];
108
+ strategy: "sequential" | "parallel";
109
+ engine?: PermissionEngine;
110
+ autoCheck?: boolean;
111
+ onAllGranted?: () => void;
112
+ }
113
+ /**
114
+ * Return type of useMultiplePermissions.
115
+ */
116
+ interface MultiplePermissionsResult {
117
+ statuses: Record<string, PermissionFlowState>;
118
+ allGranted: boolean;
119
+ request: () => void;
120
+ }
121
+
122
+ export type { BlockedPromptConfig as B, MultiplePermissionsConfig as M, PermissionEngine as P, PermissionFlowState as a, PermissionFlowEvent as b, PermissionHandlerConfig as c, PermissionHandlerResult as d, MultiplePermissionsResult as e, PermissionCallbacks as f, PrePromptConfig as g, MultiPermissionEntry as h, PermissionStatus as i };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-native-permission-handler",
3
- "version": "0.1.0",
4
- "description": "Smart permission UX flows for React Native — pre-prompts, blocked handling, settings redirect & foreground re-check. Built on react-native-permissions.",
3
+ "version": "0.2.1",
4
+ "description": "Smart permission UX flows for React Native — pre-prompts, blocked handling, settings redirect & foreground re-check. Pluggable engine: works with react-native-permissions, Expo, or your own.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
@@ -15,6 +15,26 @@
15
15
  "types": "./dist/index.d.ts",
16
16
  "default": "./dist/index.js"
17
17
  }
18
+ },
19
+ "./rnp": {
20
+ "import": {
21
+ "types": "./dist/engines/rnp.d.mts",
22
+ "default": "./dist/engines/rnp.mjs"
23
+ },
24
+ "require": {
25
+ "types": "./dist/engines/rnp.d.ts",
26
+ "default": "./dist/engines/rnp.js"
27
+ }
28
+ },
29
+ "./expo": {
30
+ "import": {
31
+ "types": "./dist/engines/expo.d.mts",
32
+ "default": "./dist/engines/expo.mjs"
33
+ },
34
+ "require": {
35
+ "types": "./dist/engines/expo.d.ts",
36
+ "default": "./dist/engines/expo.js"
37
+ }
18
38
  }
19
39
  },
20
40
  "files": [
@@ -60,6 +80,11 @@
60
80
  "react-native": ">=0.76.0",
61
81
  "react-native-permissions": ">=4.0.0"
62
82
  },
83
+ "peerDependenciesMeta": {
84
+ "react-native-permissions": {
85
+ "optional": true
86
+ }
87
+ },
63
88
  "devDependencies": {
64
89
  "@biomejs/biome": "^1.9.0",
65
90
  "@testing-library/react-native": "^12.0.0",
@@ -1,13 +1,18 @@
1
1
  import React from "react";
2
2
  import type { ReactNode } from "react";
3
- import type { Permission } from "react-native-permissions";
4
3
  import { usePermissionHandler } from "../hooks/use-permission-handler";
5
- import type { BlockedPromptConfig, PermissionCallbacks, PrePromptConfig } from "../types";
4
+ import type {
5
+ BlockedPromptConfig,
6
+ PermissionCallbacks,
7
+ PermissionEngine,
8
+ PrePromptConfig,
9
+ } from "../types";
6
10
  import { DefaultBlockedPrompt } from "./default-blocked-prompt";
7
11
  import { DefaultPrePrompt } from "./default-pre-prompt";
8
12
 
9
13
  export interface PermissionGateProps extends PermissionCallbacks {
10
- permission: Permission | "notifications";
14
+ permission: string;
15
+ engine?: PermissionEngine;
11
16
  prePrompt: PrePromptConfig;
12
17
  blockedPrompt: BlockedPromptConfig;
13
18
  children: ReactNode;
@@ -25,6 +30,7 @@ export interface PermissionGateProps extends PermissionCallbacks {
25
30
 
26
31
  export function PermissionGate({
27
32
  permission,
33
+ engine,
28
34
  prePrompt,
29
35
  blockedPrompt,
30
36
  children,
@@ -38,6 +44,7 @@ export function PermissionGate({
38
44
  }: PermissionGateProps) {
39
45
  const handler = usePermissionHandler({
40
46
  permission,
47
+ engine,
41
48
  prePrompt,
42
49
  blockedPrompt,
43
50
  onGrant,
@@ -0,0 +1,122 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ExpoPermissionModule } from "./expo";
3
+
4
+ vi.mock("react-native", () => ({
5
+ Linking: {
6
+ openSettings: vi.fn().mockResolvedValue(undefined),
7
+ },
8
+ }));
9
+
10
+ import { Linking } from "react-native";
11
+ import { createExpoEngine } from "./expo";
12
+
13
+ function createMockModule(overrides?: Partial<ExpoPermissionModule>): ExpoPermissionModule {
14
+ return {
15
+ getPermissionsAsync: vi.fn().mockResolvedValue({ status: "granted", canAskAgain: true }),
16
+ requestPermissionsAsync: vi.fn().mockResolvedValue({ status: "granted", canAskAgain: true }),
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("createExpoEngine", () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ describe("status mapping", () => {
27
+ it("maps 'granted' to 'granted'", async () => {
28
+ const camera = createMockModule({
29
+ getPermissionsAsync: vi.fn().mockResolvedValue({ status: "granted", canAskAgain: true }),
30
+ });
31
+ const engine = createExpoEngine({ permissions: { camera } });
32
+
33
+ expect(await engine.check("camera")).toBe("granted");
34
+ });
35
+
36
+ it("maps 'undetermined' to 'denied'", async () => {
37
+ const camera = createMockModule({
38
+ getPermissionsAsync: vi
39
+ .fn()
40
+ .mockResolvedValue({ status: "undetermined", canAskAgain: true }),
41
+ });
42
+ const engine = createExpoEngine({ permissions: { camera } });
43
+
44
+ expect(await engine.check("camera")).toBe("denied");
45
+ });
46
+
47
+ it("maps 'denied' + canAskAgain:true to 'denied'", async () => {
48
+ const camera = createMockModule({
49
+ getPermissionsAsync: vi.fn().mockResolvedValue({ status: "denied", canAskAgain: true }),
50
+ });
51
+ const engine = createExpoEngine({ permissions: { camera } });
52
+
53
+ expect(await engine.check("camera")).toBe("denied");
54
+ });
55
+
56
+ it("maps 'denied' + canAskAgain:false to 'blocked'", async () => {
57
+ const camera = createMockModule({
58
+ getPermissionsAsync: vi.fn().mockResolvedValue({ status: "denied", canAskAgain: false }),
59
+ });
60
+ const engine = createExpoEngine({ permissions: { camera } });
61
+
62
+ expect(await engine.check("camera")).toBe("blocked");
63
+ });
64
+
65
+ it("maps unknown status to 'unavailable'", async () => {
66
+ const camera = createMockModule({
67
+ getPermissionsAsync: vi
68
+ .fn()
69
+ .mockResolvedValue({ status: "something_else", canAskAgain: true }),
70
+ });
71
+ const engine = createExpoEngine({ permissions: { camera } });
72
+
73
+ expect(await engine.check("camera")).toBe("unavailable");
74
+ });
75
+ });
76
+
77
+ describe("check", () => {
78
+ it("calls getPermissionsAsync on the correct module", async () => {
79
+ const camera = createMockModule();
80
+ const location = createMockModule();
81
+ const engine = createExpoEngine({ permissions: { camera, location } });
82
+
83
+ await engine.check("camera");
84
+
85
+ expect(camera.getPermissionsAsync).toHaveBeenCalled();
86
+ expect(location.getPermissionsAsync).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("returns 'unavailable' for unknown permissions", async () => {
90
+ const engine = createExpoEngine({ permissions: {} });
91
+
92
+ expect(await engine.check("unknown")).toBe("unavailable");
93
+ });
94
+ });
95
+
96
+ describe("request", () => {
97
+ it("calls requestPermissionsAsync on the correct module", async () => {
98
+ const camera = createMockModule();
99
+ const engine = createExpoEngine({ permissions: { camera } });
100
+
101
+ await engine.request("camera");
102
+
103
+ expect(camera.requestPermissionsAsync).toHaveBeenCalled();
104
+ });
105
+
106
+ it("returns 'unavailable' for unknown permissions", async () => {
107
+ const engine = createExpoEngine({ permissions: {} });
108
+
109
+ expect(await engine.request("unknown")).toBe("unavailable");
110
+ });
111
+ });
112
+
113
+ describe("openSettings", () => {
114
+ it("calls Linking.openSettings()", async () => {
115
+ const engine = createExpoEngine({ permissions: {} });
116
+
117
+ await engine.openSettings();
118
+
119
+ expect(Linking.openSettings).toHaveBeenCalled();
120
+ });
121
+ });
122
+ });
@@ -0,0 +1,45 @@
1
+ import { Linking } from "react-native";
2
+ import type { PermissionEngine, PermissionStatus } from "../types";
3
+
4
+ export interface ExpoPermissionModule {
5
+ getPermissionsAsync: () => Promise<{ status: string; canAskAgain: boolean }>;
6
+ requestPermissionsAsync: () => Promise<{ status: string; canAskAgain: boolean }>;
7
+ }
8
+
9
+ export interface ExpoEngineConfig {
10
+ permissions: Record<string, ExpoPermissionModule>;
11
+ }
12
+
13
+ function mapExpoStatus(result: {
14
+ status: string;
15
+ canAskAgain: boolean;
16
+ }): PermissionStatus {
17
+ if (result.status === "granted") return "granted";
18
+ if (result.status === "undetermined") return "denied";
19
+ if (result.status === "denied") {
20
+ return result.canAskAgain ? "denied" : "blocked";
21
+ }
22
+ return "unavailable";
23
+ }
24
+
25
+ export function createExpoEngine(config: ExpoEngineConfig): PermissionEngine {
26
+ return {
27
+ async check(permission: string): Promise<PermissionStatus> {
28
+ const mod = config.permissions[permission];
29
+ if (!mod) return "unavailable";
30
+ const result = await mod.getPermissionsAsync();
31
+ return mapExpoStatus(result);
32
+ },
33
+
34
+ async request(permission: string): Promise<PermissionStatus> {
35
+ const mod = config.permissions[permission];
36
+ if (!mod) return "unavailable";
37
+ const result = await mod.requestPermissionsAsync();
38
+ return mapExpoStatus(result);
39
+ },
40
+
41
+ async openSettings(): Promise<void> {
42
+ await Linking.openSettings();
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,85 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { PermissionEngine } from "../types";
3
+
4
+ // Mock the RNP fallback
5
+ const mockFallbackEngine: PermissionEngine = {
6
+ check: vi.fn().mockResolvedValue("granted"),
7
+ request: vi.fn().mockResolvedValue("granted"),
8
+ openSettings: vi.fn().mockResolvedValue(undefined),
9
+ };
10
+
11
+ vi.mock("./rnp-fallback", () => ({
12
+ getRNPFallbackEngine: vi.fn(() => mockFallbackEngine),
13
+ }));
14
+
15
+ import { getDefaultEngine, setDefaultEngine } from "./resolve";
16
+ import { resolveEngine } from "./use-engine";
17
+
18
+ describe("resolveEngine", () => {
19
+ afterEach(() => {
20
+ // Reset global default by setting it to a known state
21
+ // We can't truly reset since there's no clearDefaultEngine, but tests
22
+ // run in order so we test the cascade explicitly
23
+ });
24
+
25
+ it("uses config engine when provided", () => {
26
+ const configEngine: PermissionEngine = {
27
+ check: vi.fn(),
28
+ request: vi.fn(),
29
+ openSettings: vi.fn(),
30
+ };
31
+
32
+ expect(resolveEngine(configEngine)).toBe(configEngine);
33
+ });
34
+
35
+ it("falls back to RNP fallback when no config or global engine", () => {
36
+ // getDefaultEngine() returns null by default (no setDefaultEngine called yet in fresh module)
37
+ // But since we can't guarantee module state across tests, we test the cascade
38
+ const result = resolveEngine(undefined);
39
+ // Should either be the global default or the fallback
40
+ expect(result).toBeDefined();
41
+ expect(result.check).toBeDefined();
42
+ expect(result.request).toBeDefined();
43
+ expect(result.openSettings).toBeDefined();
44
+ });
45
+ });
46
+
47
+ describe("setDefaultEngine / getDefaultEngine", () => {
48
+ it("stores and retrieves a default engine", () => {
49
+ const engine: PermissionEngine = {
50
+ check: vi.fn(),
51
+ request: vi.fn(),
52
+ openSettings: vi.fn(),
53
+ };
54
+
55
+ setDefaultEngine(engine);
56
+ expect(getDefaultEngine()).toBe(engine);
57
+ });
58
+
59
+ it("resolveEngine uses global default over fallback", () => {
60
+ const globalEngine: PermissionEngine = {
61
+ check: vi.fn(),
62
+ request: vi.fn(),
63
+ openSettings: vi.fn(),
64
+ };
65
+
66
+ setDefaultEngine(globalEngine);
67
+ expect(resolveEngine(undefined)).toBe(globalEngine);
68
+ });
69
+
70
+ it("config engine takes priority over global default", () => {
71
+ const globalEngine: PermissionEngine = {
72
+ check: vi.fn(),
73
+ request: vi.fn(),
74
+ openSettings: vi.fn(),
75
+ };
76
+ const configEngine: PermissionEngine = {
77
+ check: vi.fn(),
78
+ request: vi.fn(),
79
+ openSettings: vi.fn(),
80
+ };
81
+
82
+ setDefaultEngine(globalEngine);
83
+ expect(resolveEngine(configEngine)).toBe(configEngine);
84
+ });
85
+ });
@@ -0,0 +1,11 @@
1
+ import type { PermissionEngine } from "../types";
2
+
3
+ let defaultEngine: PermissionEngine | null = null;
4
+
5
+ export function setDefaultEngine(engine: PermissionEngine): void {
6
+ defaultEngine = engine;
7
+ }
8
+
9
+ export function getDefaultEngine(): PermissionEngine | null {
10
+ return defaultEngine;
11
+ }
@@ -0,0 +1,23 @@
1
+ import type { PermissionEngine } from "../types";
2
+
3
+ let cachedFallback: PermissionEngine | null = null;
4
+
5
+ export function getRNPFallbackEngine(): PermissionEngine {
6
+ if (cachedFallback) return cachedFallback;
7
+
8
+ try {
9
+ // Dynamic require to avoid hard dependency — only resolves if
10
+ // react-native-permissions is installed by the consumer.
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
+ const mod = require("./rnp") as { createRNPEngine: () => PermissionEngine };
13
+ const { createRNPEngine } = mod;
14
+ cachedFallback = createRNPEngine();
15
+ return cachedFallback;
16
+ } catch {
17
+ throw new Error(
18
+ "react-native-permission-handler: No permission engine configured. " +
19
+ "Either pass an `engine` in your hook config, call setDefaultEngine(), " +
20
+ "or install react-native-permissions as a peer dependency.",
21
+ );
22
+ }
23
+ }
@@ -0,0 +1,122 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("react-native", () => ({
4
+ Platform: { select: (opts: Record<string, string>) => opts.ios ?? opts.default },
5
+ }));
6
+
7
+ vi.mock("react-native-permissions", () => ({
8
+ check: vi.fn(),
9
+ request: vi.fn(),
10
+ openSettings: vi.fn(),
11
+ checkNotifications: vi.fn(),
12
+ requestNotifications: vi.fn(),
13
+ }));
14
+
15
+ import {
16
+ check,
17
+ checkNotifications,
18
+ openSettings,
19
+ request,
20
+ requestNotifications,
21
+ } from "react-native-permissions";
22
+ import { Permissions, createRNPEngine } from "./rnp";
23
+
24
+ describe("createRNPEngine", () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ describe("check", () => {
30
+ it("delegates to check() for regular permissions", async () => {
31
+ vi.mocked(check).mockResolvedValue("granted");
32
+ const engine = createRNPEngine();
33
+
34
+ const result = await engine.check("ios.permission.CAMERA");
35
+
36
+ expect(check).toHaveBeenCalledWith("ios.permission.CAMERA");
37
+ expect(result).toBe("granted");
38
+ });
39
+
40
+ it("delegates to checkNotifications() for 'notifications'", async () => {
41
+ vi.mocked(checkNotifications).mockResolvedValue({
42
+ status: "denied",
43
+ settings: {},
44
+ });
45
+ const engine = createRNPEngine();
46
+
47
+ const result = await engine.check("notifications");
48
+
49
+ expect(checkNotifications).toHaveBeenCalled();
50
+ expect(check).not.toHaveBeenCalled();
51
+ expect(result).toBe("denied");
52
+ });
53
+
54
+ it("returns all status values correctly", async () => {
55
+ const engine = createRNPEngine();
56
+
57
+ for (const status of ["granted", "denied", "blocked", "limited", "unavailable"] as const) {
58
+ vi.mocked(check).mockResolvedValue(status);
59
+ expect(await engine.check("some.permission")).toBe(status);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe("request", () => {
65
+ it("delegates to request() for regular permissions", async () => {
66
+ vi.mocked(request).mockResolvedValue("granted");
67
+ const engine = createRNPEngine();
68
+
69
+ const result = await engine.request("ios.permission.CAMERA");
70
+
71
+ expect(request).toHaveBeenCalledWith("ios.permission.CAMERA");
72
+ expect(result).toBe("granted");
73
+ });
74
+
75
+ it("delegates to requestNotifications() for 'notifications'", async () => {
76
+ vi.mocked(requestNotifications).mockResolvedValue({
77
+ status: "granted",
78
+ settings: {},
79
+ });
80
+ const engine = createRNPEngine();
81
+
82
+ const result = await engine.request("notifications");
83
+
84
+ expect(requestNotifications).toHaveBeenCalledWith(["alert", "badge", "sound"]);
85
+ expect(request).not.toHaveBeenCalled();
86
+ expect(result).toBe("granted");
87
+ });
88
+ });
89
+
90
+ describe("openSettings", () => {
91
+ it("delegates to openSettings()", async () => {
92
+ vi.mocked(openSettings).mockResolvedValue(undefined as never);
93
+ const engine = createRNPEngine();
94
+
95
+ await engine.openSettings();
96
+
97
+ expect(openSettings).toHaveBeenCalled();
98
+ });
99
+ });
100
+ });
101
+
102
+ describe("Permissions constants", () => {
103
+ it("resolves to iOS strings (mocked platform)", () => {
104
+ expect(Permissions.CAMERA).toBe("ios.permission.CAMERA");
105
+ expect(Permissions.MICROPHONE).toBe("ios.permission.MICROPHONE");
106
+ expect(Permissions.LOCATION_WHEN_IN_USE).toBe("ios.permission.LOCATION_WHEN_IN_USE");
107
+ expect(Permissions.NOTIFICATIONS).toBe("notifications");
108
+ });
109
+
110
+ it("includes all expected cross-platform permissions", () => {
111
+ const keys = Object.keys(Permissions);
112
+ expect(keys).toContain("CAMERA");
113
+ expect(keys).toContain("MICROPHONE");
114
+ expect(keys).toContain("CONTACTS");
115
+ expect(keys).toContain("CALENDARS");
116
+ expect(keys).toContain("LOCATION_WHEN_IN_USE");
117
+ expect(keys).toContain("LOCATION_ALWAYS");
118
+ expect(keys).toContain("PHOTO_LIBRARY");
119
+ expect(keys).toContain("BLUETOOTH");
120
+ expect(keys).toContain("NOTIFICATIONS");
121
+ });
122
+ });
@@ -0,0 +1,68 @@
1
+ import { Platform } from "react-native";
2
+ import {
3
+ type Permission,
4
+ check,
5
+ checkNotifications,
6
+ openSettings,
7
+ request,
8
+ requestNotifications,
9
+ } from "react-native-permissions";
10
+ import type { PermissionEngine, PermissionStatus } from "../types";
11
+
12
+ function p(ios: string, android: string): string {
13
+ return Platform.select({ ios, android, default: ios }) ?? ios;
14
+ }
15
+
16
+ /**
17
+ * Cross-platform permission constants for use with the RNP engine.
18
+ * Each resolves to the correct platform-specific string at runtime.
19
+ */
20
+ export const Permissions = {
21
+ CAMERA: p("ios.permission.CAMERA", "android.permission.CAMERA"),
22
+ MICROPHONE: p("ios.permission.MICROPHONE", "android.permission.RECORD_AUDIO"),
23
+ CONTACTS: p("ios.permission.CONTACTS", "android.permission.READ_CONTACTS"),
24
+ CALENDARS: p("ios.permission.CALENDARS", "android.permission.READ_CALENDAR"),
25
+ CALENDARS_WRITE_ONLY: p(
26
+ "ios.permission.CALENDARS_WRITE_ONLY",
27
+ "android.permission.WRITE_CALENDAR",
28
+ ),
29
+ LOCATION_WHEN_IN_USE: p(
30
+ "ios.permission.LOCATION_WHEN_IN_USE",
31
+ "android.permission.ACCESS_FINE_LOCATION",
32
+ ),
33
+ LOCATION_ALWAYS: p(
34
+ "ios.permission.LOCATION_ALWAYS",
35
+ "android.permission.ACCESS_BACKGROUND_LOCATION",
36
+ ),
37
+ PHOTO_LIBRARY: p("ios.permission.PHOTO_LIBRARY", "android.permission.READ_MEDIA_IMAGES"),
38
+ PHOTO_LIBRARY_ADD_ONLY: p(
39
+ "ios.permission.PHOTO_LIBRARY_ADD_ONLY",
40
+ "android.permission.WRITE_EXTERNAL_STORAGE",
41
+ ),
42
+ BLUETOOTH: p("ios.permission.BLUETOOTH", "android.permission.BLUETOOTH_CONNECT"),
43
+ NOTIFICATIONS: "notifications",
44
+ } as const;
45
+
46
+ export function createRNPEngine(): PermissionEngine {
47
+ return {
48
+ async check(permission: string): Promise<PermissionStatus> {
49
+ if (permission === "notifications") {
50
+ const result = await checkNotifications();
51
+ return result.status as PermissionStatus;
52
+ }
53
+ return (await check(permission as Permission)) as PermissionStatus;
54
+ },
55
+
56
+ async request(permission: string): Promise<PermissionStatus> {
57
+ if (permission === "notifications") {
58
+ const result = await requestNotifications(["alert", "badge", "sound"]);
59
+ return result.status as PermissionStatus;
60
+ }
61
+ return (await request(permission as Permission)) as PermissionStatus;
62
+ },
63
+
64
+ async openSettings(): Promise<void> {
65
+ await openSettings();
66
+ },
67
+ };
68
+ }