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.
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
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.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./dist/index.d.mts",
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "dev": "tsup --watch",
27
+ "test": "vitest run --passWithNoTests",
28
+ "test:watch": "vitest",
29
+ "lint": "biome check .",
30
+ "lint:fix": "biome check --fix .",
31
+ "typecheck": "tsc --noEmit",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "keywords": [
35
+ "react-native",
36
+ "permissions",
37
+ "permission",
38
+ "permission-flow",
39
+ "soft-prompt",
40
+ "pre-prompt",
41
+ "permission-ux",
42
+ "ios",
43
+ "android",
44
+ "settings",
45
+ "runtime-permissions",
46
+ "permission-handler"
47
+ ],
48
+ "author": "Radu Ghitescu",
49
+ "license": "MIT",
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/radughitescu/react-native-permission-handler.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/radughitescu/react-native-permission-handler/issues"
56
+ },
57
+ "homepage": "https://github.com/radughitescu/react-native-permission-handler#readme",
58
+ "peerDependencies": {
59
+ "react": ">=18.0.0",
60
+ "react-native": ">=0.76.0",
61
+ "react-native-permissions": ">=4.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@biomejs/biome": "^1.9.0",
65
+ "@testing-library/react-native": "^12.0.0",
66
+ "@types/react": "^18.0.0",
67
+ "react": "^18.0.0",
68
+ "react-native": "^0.76.0",
69
+ "react-native-permissions": "^5.5.1",
70
+ "react-test-renderer": "^18.0.0",
71
+ "tsup": "^8.0.0",
72
+ "typescript": "^5.5.0",
73
+ "vitest": "^3.0.0"
74
+ }
75
+ }
@@ -0,0 +1,76 @@
1
+ import React from "react";
2
+ import { Modal, StyleSheet, Text, TouchableOpacity, View } from "react-native";
3
+ import type { BlockedPromptConfig } from "../types";
4
+
5
+ export interface DefaultBlockedPromptProps extends BlockedPromptConfig {
6
+ visible: boolean;
7
+ onOpenSettings: () => void;
8
+ }
9
+
10
+ export function DefaultBlockedPrompt({
11
+ visible,
12
+ title,
13
+ message,
14
+ settingsLabel = "Open Settings",
15
+ onOpenSettings,
16
+ }: DefaultBlockedPromptProps) {
17
+ return (
18
+ <Modal visible={visible} transparent animationType="fade">
19
+ <View style={styles.overlay}>
20
+ <View style={styles.modal}>
21
+ <Text style={styles.title}>{title}</Text>
22
+ <Text style={styles.message}>{message}</Text>
23
+ <TouchableOpacity
24
+ style={styles.settingsButton}
25
+ onPress={onOpenSettings}
26
+ accessibilityRole="button"
27
+ >
28
+ <Text style={styles.settingsText}>{settingsLabel}</Text>
29
+ </TouchableOpacity>
30
+ </View>
31
+ </View>
32
+ </Modal>
33
+ );
34
+ }
35
+
36
+ const styles = StyleSheet.create({
37
+ overlay: {
38
+ flex: 1,
39
+ backgroundColor: "rgba(0,0,0,0.5)",
40
+ justifyContent: "center",
41
+ alignItems: "center",
42
+ },
43
+ modal: {
44
+ backgroundColor: "white",
45
+ borderRadius: 16,
46
+ padding: 24,
47
+ width: "85%",
48
+ alignItems: "center",
49
+ },
50
+ title: {
51
+ fontSize: 20,
52
+ fontWeight: "600",
53
+ marginBottom: 12,
54
+ textAlign: "center",
55
+ },
56
+ message: {
57
+ fontSize: 15,
58
+ color: "#666",
59
+ textAlign: "center",
60
+ marginBottom: 24,
61
+ lineHeight: 22,
62
+ },
63
+ settingsButton: {
64
+ backgroundColor: "#007AFF",
65
+ borderRadius: 12,
66
+ paddingVertical: 14,
67
+ paddingHorizontal: 32,
68
+ width: "100%",
69
+ alignItems: "center",
70
+ },
71
+ settingsText: {
72
+ color: "white",
73
+ fontSize: 16,
74
+ fontWeight: "600",
75
+ },
76
+ });
@@ -0,0 +1,87 @@
1
+ import React from "react";
2
+ import { Modal, StyleSheet, Text, TouchableOpacity, View } from "react-native";
3
+ import type { PrePromptConfig } from "../types";
4
+
5
+ export interface DefaultPrePromptProps extends PrePromptConfig {
6
+ visible: boolean;
7
+ onConfirm: () => void;
8
+ onCancel: () => void;
9
+ }
10
+
11
+ export function DefaultPrePrompt({
12
+ visible,
13
+ title,
14
+ message,
15
+ confirmLabel = "Continue",
16
+ cancelLabel = "Not Now",
17
+ onConfirm,
18
+ onCancel,
19
+ }: DefaultPrePromptProps) {
20
+ return (
21
+ <Modal visible={visible} transparent animationType="fade">
22
+ <View style={styles.overlay}>
23
+ <View style={styles.modal}>
24
+ <Text style={styles.title}>{title}</Text>
25
+ <Text style={styles.message}>{message}</Text>
26
+ <TouchableOpacity
27
+ style={styles.confirmButton}
28
+ onPress={onConfirm}
29
+ accessibilityRole="button"
30
+ >
31
+ <Text style={styles.confirmText}>{confirmLabel}</Text>
32
+ </TouchableOpacity>
33
+ <TouchableOpacity onPress={onCancel} accessibilityRole="button">
34
+ <Text style={styles.cancelText}>{cancelLabel}</Text>
35
+ </TouchableOpacity>
36
+ </View>
37
+ </View>
38
+ </Modal>
39
+ );
40
+ }
41
+
42
+ const styles = StyleSheet.create({
43
+ overlay: {
44
+ flex: 1,
45
+ backgroundColor: "rgba(0,0,0,0.5)",
46
+ justifyContent: "center",
47
+ alignItems: "center",
48
+ },
49
+ modal: {
50
+ backgroundColor: "white",
51
+ borderRadius: 16,
52
+ padding: 24,
53
+ width: "85%",
54
+ alignItems: "center",
55
+ },
56
+ title: {
57
+ fontSize: 20,
58
+ fontWeight: "600",
59
+ marginBottom: 12,
60
+ textAlign: "center",
61
+ },
62
+ message: {
63
+ fontSize: 15,
64
+ color: "#666",
65
+ textAlign: "center",
66
+ marginBottom: 24,
67
+ lineHeight: 22,
68
+ },
69
+ confirmButton: {
70
+ backgroundColor: "#007AFF",
71
+ borderRadius: 12,
72
+ paddingVertical: 14,
73
+ paddingHorizontal: 32,
74
+ width: "100%",
75
+ alignItems: "center",
76
+ marginBottom: 12,
77
+ },
78
+ confirmText: {
79
+ color: "white",
80
+ fontSize: 16,
81
+ fontWeight: "600",
82
+ },
83
+ cancelText: {
84
+ color: "#007AFF",
85
+ fontSize: 15,
86
+ },
87
+ });
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+ import type { ReactNode } from "react";
3
+ import type { Permission } from "react-native-permissions";
4
+ import { usePermissionHandler } from "../hooks/use-permission-handler";
5
+ import type { BlockedPromptConfig, PermissionCallbacks, PrePromptConfig } from "../types";
6
+ import { DefaultBlockedPrompt } from "./default-blocked-prompt";
7
+ import { DefaultPrePrompt } from "./default-pre-prompt";
8
+
9
+ export interface PermissionGateProps extends PermissionCallbacks {
10
+ permission: Permission | "notifications";
11
+ prePrompt: PrePromptConfig;
12
+ blockedPrompt: BlockedPromptConfig;
13
+ children: ReactNode;
14
+ fallback?: ReactNode;
15
+ renderPrePrompt?: (props: {
16
+ config: PrePromptConfig;
17
+ onConfirm: () => void;
18
+ onCancel: () => void;
19
+ }) => ReactNode;
20
+ renderBlockedPrompt?: (props: {
21
+ config: BlockedPromptConfig;
22
+ onOpenSettings: () => void;
23
+ }) => ReactNode;
24
+ }
25
+
26
+ export function PermissionGate({
27
+ permission,
28
+ prePrompt,
29
+ blockedPrompt,
30
+ children,
31
+ fallback = null,
32
+ renderPrePrompt,
33
+ renderBlockedPrompt,
34
+ onGrant,
35
+ onDeny,
36
+ onBlock,
37
+ onSettingsReturn,
38
+ }: PermissionGateProps) {
39
+ const handler = usePermissionHandler({
40
+ permission,
41
+ prePrompt,
42
+ blockedPrompt,
43
+ onGrant,
44
+ onDeny,
45
+ onBlock,
46
+ onSettingsReturn,
47
+ });
48
+
49
+ if (handler.isGranted) {
50
+ return <>{children}</>;
51
+ }
52
+
53
+ if (handler.isChecking || handler.isUnavailable) {
54
+ return <>{fallback}</>;
55
+ }
56
+
57
+ if (handler.state === "prePrompt") {
58
+ if (renderPrePrompt) {
59
+ return (
60
+ <>
61
+ {renderPrePrompt({
62
+ config: prePrompt,
63
+ onConfirm: handler.request,
64
+ onCancel: handler.dismiss,
65
+ })}
66
+ </>
67
+ );
68
+ }
69
+ return (
70
+ <DefaultPrePrompt
71
+ visible
72
+ title={prePrompt.title}
73
+ message={prePrompt.message}
74
+ confirmLabel={prePrompt.confirmLabel}
75
+ cancelLabel={prePrompt.cancelLabel}
76
+ onConfirm={handler.request}
77
+ onCancel={handler.dismiss}
78
+ />
79
+ );
80
+ }
81
+
82
+ if (handler.state === "blockedPrompt") {
83
+ if (renderBlockedPrompt) {
84
+ return (
85
+ <>
86
+ {renderBlockedPrompt({
87
+ config: blockedPrompt,
88
+ onOpenSettings: handler.openSettings,
89
+ })}
90
+ </>
91
+ );
92
+ }
93
+ return (
94
+ <DefaultBlockedPrompt
95
+ visible
96
+ title={blockedPrompt.title}
97
+ message={blockedPrompt.message}
98
+ settingsLabel={blockedPrompt.settingsLabel}
99
+ onOpenSettings={handler.openSettings}
100
+ />
101
+ );
102
+ }
103
+
104
+ return <>{fallback}</>;
105
+ }
@@ -0,0 +1,234 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PermissionFlowEvent, PermissionFlowState } from "../types";
3
+ import { transition } from "./state-machine";
4
+
5
+ describe("transition — idle", () => {
6
+ it("transitions to checking on CHECK", () => {
7
+ expect(transition("idle", { type: "CHECK" })).toBe("checking");
8
+ });
9
+
10
+ it("ignores unrelated events", () => {
11
+ expect(transition("idle", { type: "PRE_PROMPT_CONFIRM" })).toBe("idle");
12
+ expect(transition("idle", { type: "PRE_PROMPT_DISMISS" })).toBe("idle");
13
+ expect(transition("idle", { type: "OPEN_SETTINGS" })).toBe("idle");
14
+ expect(transition("idle", { type: "SETTINGS_RETURN" })).toBe("idle");
15
+ });
16
+ });
17
+
18
+ describe("transition — checking", () => {
19
+ it("transitions to granted on granted status", () => {
20
+ expect(transition("checking", { type: "CHECK_RESULT", status: "granted" })).toBe("granted");
21
+ });
22
+
23
+ it("transitions to granted on limited status", () => {
24
+ expect(transition("checking", { type: "CHECK_RESULT", status: "limited" })).toBe("granted");
25
+ });
26
+
27
+ it("transitions to prePrompt on denied status", () => {
28
+ expect(transition("checking", { type: "CHECK_RESULT", status: "denied" })).toBe("prePrompt");
29
+ });
30
+
31
+ it("transitions to blockedPrompt on blocked status", () => {
32
+ expect(transition("checking", { type: "CHECK_RESULT", status: "blocked" })).toBe(
33
+ "blockedPrompt",
34
+ );
35
+ });
36
+
37
+ it("transitions to unavailable on unavailable status", () => {
38
+ expect(transition("checking", { type: "CHECK_RESULT", status: "unavailable" })).toBe(
39
+ "unavailable",
40
+ );
41
+ });
42
+
43
+ it("ignores unrelated events", () => {
44
+ expect(transition("checking", { type: "CHECK" })).toBe("checking");
45
+ expect(transition("checking", { type: "PRE_PROMPT_CONFIRM" })).toBe("checking");
46
+ });
47
+ });
48
+
49
+ describe("transition — prePrompt", () => {
50
+ it("transitions to requesting on PRE_PROMPT_CONFIRM", () => {
51
+ expect(transition("prePrompt", { type: "PRE_PROMPT_CONFIRM" })).toBe("requesting");
52
+ });
53
+
54
+ it("transitions to denied on PRE_PROMPT_DISMISS", () => {
55
+ expect(transition("prePrompt", { type: "PRE_PROMPT_DISMISS" })).toBe("denied");
56
+ });
57
+
58
+ it("ignores unrelated events", () => {
59
+ expect(transition("prePrompt", { type: "CHECK" })).toBe("prePrompt");
60
+ expect(transition("prePrompt", { type: "OPEN_SETTINGS" })).toBe("prePrompt");
61
+ });
62
+ });
63
+
64
+ describe("transition — requesting", () => {
65
+ it("transitions to granted on granted status", () => {
66
+ expect(transition("requesting", { type: "REQUEST_RESULT", status: "granted" })).toBe("granted");
67
+ });
68
+
69
+ it("transitions to granted on limited status", () => {
70
+ expect(transition("requesting", { type: "REQUEST_RESULT", status: "limited" })).toBe("granted");
71
+ });
72
+
73
+ it("transitions to denied on denied status", () => {
74
+ expect(transition("requesting", { type: "REQUEST_RESULT", status: "denied" })).toBe("denied");
75
+ });
76
+
77
+ it("transitions to blockedPrompt on blocked status", () => {
78
+ expect(transition("requesting", { type: "REQUEST_RESULT", status: "blocked" })).toBe(
79
+ "blockedPrompt",
80
+ );
81
+ });
82
+
83
+ it("ignores unrelated events", () => {
84
+ expect(transition("requesting", { type: "CHECK" })).toBe("requesting");
85
+ expect(transition("requesting", { type: "PRE_PROMPT_CONFIRM" })).toBe("requesting");
86
+ });
87
+ });
88
+
89
+ describe("transition — blockedPrompt", () => {
90
+ it("transitions to openingSettings on OPEN_SETTINGS", () => {
91
+ expect(transition("blockedPrompt", { type: "OPEN_SETTINGS" })).toBe("openingSettings");
92
+ });
93
+
94
+ it("ignores unrelated events", () => {
95
+ expect(transition("blockedPrompt", { type: "CHECK" })).toBe("blockedPrompt");
96
+ expect(transition("blockedPrompt", { type: "PRE_PROMPT_CONFIRM" })).toBe("blockedPrompt");
97
+ });
98
+ });
99
+
100
+ describe("transition — openingSettings", () => {
101
+ it("transitions to recheckingAfterSettings on SETTINGS_RETURN", () => {
102
+ expect(transition("openingSettings", { type: "SETTINGS_RETURN" })).toBe(
103
+ "recheckingAfterSettings",
104
+ );
105
+ });
106
+
107
+ it("ignores unrelated events", () => {
108
+ expect(transition("openingSettings", { type: "CHECK" })).toBe("openingSettings");
109
+ expect(transition("openingSettings", { type: "OPEN_SETTINGS" })).toBe("openingSettings");
110
+ });
111
+ });
112
+
113
+ describe("transition — recheckingAfterSettings", () => {
114
+ it("transitions to granted on granted status", () => {
115
+ expect(
116
+ transition("recheckingAfterSettings", {
117
+ type: "RECHECK_RESULT",
118
+ status: "granted",
119
+ }),
120
+ ).toBe("granted");
121
+ });
122
+
123
+ it("transitions to granted on limited status", () => {
124
+ expect(
125
+ transition("recheckingAfterSettings", {
126
+ type: "RECHECK_RESULT",
127
+ status: "limited",
128
+ }),
129
+ ).toBe("granted");
130
+ });
131
+
132
+ it("transitions to blockedPrompt on blocked status", () => {
133
+ expect(
134
+ transition("recheckingAfterSettings", {
135
+ type: "RECHECK_RESULT",
136
+ status: "blocked",
137
+ }),
138
+ ).toBe("blockedPrompt");
139
+ });
140
+
141
+ it("transitions to blockedPrompt on denied status", () => {
142
+ expect(
143
+ transition("recheckingAfterSettings", {
144
+ type: "RECHECK_RESULT",
145
+ status: "denied",
146
+ }),
147
+ ).toBe("blockedPrompt");
148
+ });
149
+
150
+ it("ignores unrelated events", () => {
151
+ expect(transition("recheckingAfterSettings", { type: "CHECK" })).toBe(
152
+ "recheckingAfterSettings",
153
+ );
154
+ });
155
+ });
156
+
157
+ describe("transition — terminal states allow re-checking", () => {
158
+ it("granted → checking on CHECK", () => {
159
+ expect(transition("granted", { type: "CHECK" })).toBe("checking");
160
+ });
161
+
162
+ it("denied → checking on CHECK", () => {
163
+ expect(transition("denied", { type: "CHECK" })).toBe("checking");
164
+ });
165
+
166
+ it("unavailable → checking on CHECK", () => {
167
+ expect(transition("unavailable", { type: "CHECK" })).toBe("checking");
168
+ });
169
+
170
+ it("granted ignores unrelated events", () => {
171
+ expect(transition("granted", { type: "OPEN_SETTINGS" })).toBe("granted");
172
+ });
173
+
174
+ it("denied ignores unrelated events", () => {
175
+ expect(transition("denied", { type: "OPEN_SETTINGS" })).toBe("denied");
176
+ });
177
+
178
+ it("unavailable ignores unrelated events", () => {
179
+ expect(transition("unavailable", { type: "OPEN_SETTINGS" })).toBe("unavailable");
180
+ });
181
+ });
182
+
183
+ describe("transition — blocked state", () => {
184
+ it("stays blocked on any event", () => {
185
+ expect(transition("blocked", { type: "CHECK" })).toBe("blocked");
186
+ expect(transition("blocked", { type: "OPEN_SETTINGS" })).toBe("blocked");
187
+ expect(transition("blocked", { type: "PRE_PROMPT_CONFIRM" })).toBe("blocked");
188
+ });
189
+ });
190
+
191
+ describe("transition — robustness: no state throws on any event", () => {
192
+ const allStates: PermissionFlowState[] = [
193
+ "idle",
194
+ "checking",
195
+ "prePrompt",
196
+ "requesting",
197
+ "granted",
198
+ "denied",
199
+ "blocked",
200
+ "blockedPrompt",
201
+ "openingSettings",
202
+ "recheckingAfterSettings",
203
+ "unavailable",
204
+ ];
205
+
206
+ const allEvents: PermissionFlowEvent[] = [
207
+ { type: "CHECK" },
208
+ { type: "CHECK_RESULT", status: "granted" },
209
+ { type: "CHECK_RESULT", status: "denied" },
210
+ { type: "CHECK_RESULT", status: "blocked" },
211
+ { type: "CHECK_RESULT", status: "unavailable" },
212
+ { type: "CHECK_RESULT", status: "limited" },
213
+ { type: "PRE_PROMPT_CONFIRM" },
214
+ { type: "PRE_PROMPT_DISMISS" },
215
+ { type: "REQUEST_RESULT", status: "granted" },
216
+ { type: "REQUEST_RESULT", status: "denied" },
217
+ { type: "REQUEST_RESULT", status: "blocked" },
218
+ { type: "REQUEST_RESULT", status: "limited" },
219
+ { type: "OPEN_SETTINGS" },
220
+ { type: "SETTINGS_RETURN" },
221
+ { type: "RECHECK_RESULT", status: "granted" },
222
+ { type: "RECHECK_RESULT", status: "denied" },
223
+ { type: "RECHECK_RESULT", status: "blocked" },
224
+ { type: "RECHECK_RESULT", status: "limited" },
225
+ ];
226
+
227
+ for (const state of allStates) {
228
+ for (const event of allEvents) {
229
+ it(`does not throw: state=${state}, event=${event.type}`, () => {
230
+ expect(() => transition(state, event)).not.toThrow();
231
+ });
232
+ }
233
+ }
234
+ });
@@ -0,0 +1,87 @@
1
+ import type { PermissionFlowEvent, PermissionFlowState } from "../types";
2
+
3
+ export function transition(
4
+ state: PermissionFlowState,
5
+ event: PermissionFlowEvent,
6
+ ): PermissionFlowState {
7
+ switch (state) {
8
+ case "idle":
9
+ if (event.type === "CHECK") return "checking";
10
+ return state;
11
+
12
+ case "checking":
13
+ if (event.type === "CHECK_RESULT") {
14
+ switch (event.status) {
15
+ case "granted":
16
+ case "limited":
17
+ return "granted";
18
+ case "denied":
19
+ return "prePrompt";
20
+ case "blocked":
21
+ return "blockedPrompt";
22
+ case "unavailable":
23
+ return "unavailable";
24
+ default:
25
+ return state;
26
+ }
27
+ }
28
+ return state;
29
+
30
+ case "prePrompt":
31
+ if (event.type === "PRE_PROMPT_CONFIRM") return "requesting";
32
+ if (event.type === "PRE_PROMPT_DISMISS") return "denied";
33
+ return state;
34
+
35
+ case "requesting":
36
+ if (event.type === "REQUEST_RESULT") {
37
+ switch (event.status) {
38
+ case "granted":
39
+ case "limited":
40
+ return "granted";
41
+ case "denied":
42
+ return "denied";
43
+ case "blocked":
44
+ return "blockedPrompt";
45
+ default:
46
+ return state;
47
+ }
48
+ }
49
+ return state;
50
+
51
+ case "blockedPrompt":
52
+ if (event.type === "OPEN_SETTINGS") return "openingSettings";
53
+ return state;
54
+
55
+ case "openingSettings":
56
+ if (event.type === "SETTINGS_RETURN") return "recheckingAfterSettings";
57
+ return state;
58
+
59
+ case "recheckingAfterSettings":
60
+ if (event.type === "RECHECK_RESULT") {
61
+ switch (event.status) {
62
+ case "granted":
63
+ case "limited":
64
+ return "granted";
65
+ case "blocked":
66
+ return "blockedPrompt";
67
+ case "denied":
68
+ return "blockedPrompt";
69
+ default:
70
+ return state;
71
+ }
72
+ }
73
+ return state;
74
+
75
+ case "granted":
76
+ case "denied":
77
+ case "unavailable":
78
+ if (event.type === "CHECK") return "checking";
79
+ return state;
80
+
81
+ case "blocked":
82
+ return state;
83
+
84
+ default:
85
+ return state;
86
+ }
87
+ }