@ankhorage/permissions 0.2.3 → 0.2.5

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 (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -3
  3. package/dist/expo/client.js +16 -13
  4. package/dist/expo/client.js.map +1 -1
  5. package/dist/expo/client.testSupport.d.ts +54 -0
  6. package/dist/expo/client.testSupport.d.ts.map +1 -0
  7. package/dist/expo/client.testSupport.js +183 -0
  8. package/dist/expo/client.testSupport.js.map +1 -0
  9. package/dist/expo/manifest.d.ts.map +1 -1
  10. package/dist/expo/manifest.js.map +1 -1
  11. package/dist/expo/permissionResponse.js +4 -4
  12. package/dist/expo/permissionResponse.js.map +1 -1
  13. package/dist/react/index.d.ts.map +1 -1
  14. package/dist/react/index.js +16 -11
  15. package/dist/react/index.js.map +1 -1
  16. package/dist/state/permissionState.d.ts.map +1 -1
  17. package/dist/state/permissionState.js.map +1 -1
  18. package/dist/testing/index.d.ts.map +1 -1
  19. package/dist/testing/index.js +27 -17
  20. package/dist/testing/index.js.map +1 -1
  21. package/dist/web/index.d.ts +1 -2
  22. package/dist/web/index.d.ts.map +1 -1
  23. package/dist/web/index.js +2 -76
  24. package/dist/web/index.js.map +1 -1
  25. package/dist/web/permissionStates.d.ts +10 -0
  26. package/dist/web/permissionStates.d.ts.map +1 -0
  27. package/dist/web/permissionStates.js +64 -0
  28. package/dist/web/permissionStates.js.map +1 -0
  29. package/package.json +29 -22
  30. package/src/expo/client.notifications.test.ts +106 -0
  31. package/src/expo/client.test.ts +110 -535
  32. package/src/expo/client.testSupport.ts +188 -0
  33. package/src/expo/client.ts +17 -13
  34. package/src/expo/manifest.test.ts +48 -50
  35. package/src/expo/manifest.ts +1 -5
  36. package/src/expo/permissionResponse.ts +4 -4
  37. package/src/manager/createPermissionManager.test.ts +48 -50
  38. package/src/react/index.tsx +21 -13
  39. package/src/registry/permissions.test.ts +15 -3
  40. package/src/state/permissionState.ts +1 -6
  41. package/src/testing/index.ts +36 -25
  42. package/src/web/index.test.ts +149 -151
  43. package/src/web/index.ts +9 -102
  44. package/src/web/permissionStates.ts +83 -0
@@ -0,0 +1,188 @@
1
+ import { mock } from 'bun:test';
2
+
3
+ export interface MockPermissionResponse {
4
+ readonly status: string;
5
+ readonly canAskAgain?: boolean;
6
+ readonly accessPrivileges?: 'all' | 'limited' | 'none';
7
+ readonly ios?: { readonly status?: number };
8
+ }
9
+
10
+ type CameraMockMode = 'topLevel' | 'cameraNamespace' | 'missingExports';
11
+ type AudioMockMode = 'topLevel' | 'audioModule' | 'missingExports';
12
+
13
+ export function permissionResponse(status: string, canAskAgain = true): MockPermissionResponse {
14
+ return { status, canAskAgain };
15
+ }
16
+
17
+ export const cameraMock = {
18
+ mode: 'topLevel' as CameraMockMode,
19
+ getResponse: permissionResponse('granted'),
20
+ requestResponse: permissionResponse('granted'),
21
+ getError: undefined as string | undefined,
22
+ requestError: undefined as string | undefined,
23
+ };
24
+ export const audioMock = {
25
+ mode: 'topLevel' as AudioMockMode,
26
+ getResponse: permissionResponse('granted'),
27
+ requestResponse: permissionResponse('granted'),
28
+ getError: undefined as string | undefined,
29
+ requestError: undefined as string | undefined,
30
+ };
31
+ export const mediaLibraryMock = {
32
+ getResponse: permissionResponse('granted'),
33
+ requestResponse: permissionResponse('granted'),
34
+ getError: undefined as string | undefined,
35
+ requestError: undefined as string | undefined,
36
+ getCalls: [] as boolean[],
37
+ requestCalls: [] as boolean[],
38
+ };
39
+ export const locationMock = {
40
+ foregroundGetResponse: permissionResponse('granted'),
41
+ foregroundRequestResponse: permissionResponse('granted'),
42
+ backgroundGetResponse: permissionResponse('granted'),
43
+ backgroundRequestResponse: permissionResponse('granted'),
44
+ getCalls: [] as string[],
45
+ requestCalls: [] as string[],
46
+ };
47
+ export const notificationsMock = {
48
+ getResponse: permissionResponse('granted'),
49
+ requestResponse: permissionResponse('granted'),
50
+ getError: undefined as string | undefined,
51
+ requestError: undefined as string | undefined,
52
+ };
53
+ export const linkingMock = { calls: 0, error: undefined as Error | undefined };
54
+
55
+ void mock.module('expo-camera', () => ({
56
+ get getCameraPermissionsAsync() {
57
+ if (cameraMock.mode !== 'topLevel') return undefined;
58
+ return () => {
59
+ if (cameraMock.getError !== undefined) throw new Error(cameraMock.getError);
60
+ return Promise.resolve(cameraMock.getResponse);
61
+ };
62
+ },
63
+ get requestCameraPermissionsAsync() {
64
+ if (cameraMock.mode !== 'topLevel') return undefined;
65
+ return () => {
66
+ if (cameraMock.requestError !== undefined) throw new Error(cameraMock.requestError);
67
+ return Promise.resolve(cameraMock.requestResponse);
68
+ };
69
+ },
70
+ get Camera() {
71
+ if (cameraMock.mode !== 'cameraNamespace') return undefined;
72
+ return {
73
+ getCameraPermissionsAsync: () => Promise.resolve(cameraMock.getResponse),
74
+ requestCameraPermissionsAsync: () => Promise.resolve(cameraMock.requestResponse),
75
+ };
76
+ },
77
+ }));
78
+
79
+ void mock.module('expo-audio', () => ({
80
+ get getRecordingPermissionsAsync() {
81
+ if (audioMock.mode !== 'topLevel') return undefined;
82
+ return () => {
83
+ if (audioMock.getError !== undefined) throw new Error(audioMock.getError);
84
+ return Promise.resolve(audioMock.getResponse);
85
+ };
86
+ },
87
+ get requestRecordingPermissionsAsync() {
88
+ if (audioMock.mode !== 'topLevel') return undefined;
89
+ return () => {
90
+ if (audioMock.requestError !== undefined) throw new Error(audioMock.requestError);
91
+ return Promise.resolve(audioMock.requestResponse);
92
+ };
93
+ },
94
+ get AudioModule() {
95
+ if (audioMock.mode !== 'audioModule') return undefined;
96
+ return {
97
+ getRecordingPermissionsAsync: () => Promise.resolve(audioMock.getResponse),
98
+ requestRecordingPermissionsAsync: () => Promise.resolve(audioMock.requestResponse),
99
+ };
100
+ },
101
+ }));
102
+
103
+ void mock.module('expo-media-library', () => ({
104
+ getPermissionsAsync: (writeOnly?: boolean) => {
105
+ mediaLibraryMock.getCalls.push(writeOnly === true);
106
+ if (mediaLibraryMock.getError !== undefined) throw new Error(mediaLibraryMock.getError);
107
+ return Promise.resolve(mediaLibraryMock.getResponse);
108
+ },
109
+ requestPermissionsAsync: (writeOnly?: boolean) => {
110
+ mediaLibraryMock.requestCalls.push(writeOnly === true);
111
+ if (mediaLibraryMock.requestError !== undefined) throw new Error(mediaLibraryMock.requestError);
112
+ return Promise.resolve(mediaLibraryMock.requestResponse);
113
+ },
114
+ }));
115
+
116
+ void mock.module('expo-location', () => ({
117
+ getForegroundPermissionsAsync: () => {
118
+ locationMock.getCalls.push('foreground');
119
+ return Promise.resolve(locationMock.foregroundGetResponse);
120
+ },
121
+ requestForegroundPermissionsAsync: () => {
122
+ locationMock.requestCalls.push('foreground');
123
+ return Promise.resolve(locationMock.foregroundRequestResponse);
124
+ },
125
+ getBackgroundPermissionsAsync: () => {
126
+ locationMock.getCalls.push('background');
127
+ return Promise.resolve(locationMock.backgroundGetResponse);
128
+ },
129
+ requestBackgroundPermissionsAsync: () => {
130
+ locationMock.requestCalls.push('background');
131
+ return Promise.resolve(locationMock.backgroundRequestResponse);
132
+ },
133
+ }));
134
+
135
+ void mock.module('expo-notifications', () => ({
136
+ getPermissionsAsync: () => {
137
+ if (notificationsMock.getError !== undefined) throw new Error(notificationsMock.getError);
138
+ return Promise.resolve(notificationsMock.getResponse);
139
+ },
140
+ requestPermissionsAsync: () => {
141
+ if (notificationsMock.requestError !== undefined)
142
+ throw new Error(notificationsMock.requestError);
143
+ return Promise.resolve(notificationsMock.requestResponse);
144
+ },
145
+ }));
146
+
147
+ void mock.module('expo-linking', () => ({
148
+ openSettings: () => {
149
+ linkingMock.calls += 1;
150
+ if (linkingMock.error !== undefined) throw linkingMock.error;
151
+ return Promise.resolve();
152
+ },
153
+ }));
154
+
155
+ export function resetExpoMocks(): void {
156
+ cameraMock.mode = 'topLevel';
157
+ cameraMock.getResponse = permissionResponse('granted');
158
+ cameraMock.requestResponse = permissionResponse('granted');
159
+ cameraMock.getError = undefined;
160
+ cameraMock.requestError = undefined;
161
+ audioMock.mode = 'topLevel';
162
+ audioMock.getResponse = permissionResponse('granted');
163
+ audioMock.requestResponse = permissionResponse('granted');
164
+ audioMock.getError = undefined;
165
+ audioMock.requestError = undefined;
166
+ resetCollectionMocks();
167
+ notificationsMock.getResponse = permissionResponse('granted');
168
+ notificationsMock.requestResponse = permissionResponse('granted');
169
+ notificationsMock.getError = undefined;
170
+ notificationsMock.requestError = undefined;
171
+ linkingMock.calls = 0;
172
+ linkingMock.error = undefined;
173
+ }
174
+
175
+ function resetCollectionMocks(): void {
176
+ mediaLibraryMock.getResponse = permissionResponse('granted');
177
+ mediaLibraryMock.requestResponse = permissionResponse('granted');
178
+ mediaLibraryMock.getError = undefined;
179
+ mediaLibraryMock.requestError = undefined;
180
+ mediaLibraryMock.getCalls.length = 0;
181
+ mediaLibraryMock.requestCalls.length = 0;
182
+ locationMock.foregroundGetResponse = permissionResponse('granted');
183
+ locationMock.foregroundRequestResponse = permissionResponse('granted');
184
+ locationMock.backgroundGetResponse = permissionResponse('granted');
185
+ locationMock.backgroundRequestResponse = permissionResponse('granted');
186
+ locationMock.getCalls.length = 0;
187
+ locationMock.requestCalls.length = 0;
188
+ }
@@ -15,15 +15,15 @@ export interface ExpoPermissionAdapter {
15
15
  request(permission: Permission): Promise<PermissionState>;
16
16
  }
17
17
 
18
- const adapters: Partial<Record<Permission, ExpoPermissionAdapter>> = {
19
- [Permission.Camera]: cameraAdapter,
20
- [Permission.MediaLibrary]: mediaLibraryAdapter,
21
- [Permission.MediaLibraryWrite]: mediaLibraryAdapter,
22
- [Permission.Microphone]: microphoneAdapter,
23
- [Permission.LocationForeground]: locationAdapter,
24
- [Permission.LocationBackground]: locationAdapter,
25
- [Permission.Notifications]: notificationsAdapter,
26
- };
18
+ const adapters = new Map<Permission, ExpoPermissionAdapter>([
19
+ [Permission.Camera, cameraAdapter],
20
+ [Permission.MediaLibrary, mediaLibraryAdapter],
21
+ [Permission.MediaLibraryWrite, mediaLibraryAdapter],
22
+ [Permission.Microphone, microphoneAdapter],
23
+ [Permission.LocationForeground, locationAdapter],
24
+ [Permission.LocationBackground, locationAdapter],
25
+ [Permission.Notifications, notificationsAdapter],
26
+ ]);
27
27
 
28
28
  /***
29
29
  * Creates a permission client backed by Expo SDK modules.
@@ -42,7 +42,7 @@ const adapters: Partial<Record<Permission, ExpoPermissionAdapter>> = {
42
42
  export function createPermissionClient(): PermissionClient {
43
43
  return {
44
44
  async getStatus(permission: Permission): Promise<PermissionState> {
45
- const metadata = EXPO_PERMISSION_SUPPORT[permission];
45
+ const metadata = getPermissionSupport(permission);
46
46
  if (metadata.support === 'unsupported' || metadata.support === 'notImplemented') {
47
47
  return createUnavailableSupportState(permission);
48
48
  }
@@ -51,7 +51,7 @@ export function createPermissionClient(): PermissionClient {
51
51
  return createNotRequiredState(permission);
52
52
  }
53
53
 
54
- const adapter = adapters[permission];
54
+ const adapter = adapters.get(permission);
55
55
  if (!adapter) {
56
56
  throw new Error(`No adapter registered for permission: ${permission}`);
57
57
  }
@@ -60,7 +60,7 @@ export function createPermissionClient(): PermissionClient {
60
60
  },
61
61
 
62
62
  async request(permission: Permission): Promise<PermissionState> {
63
- const metadata = EXPO_PERMISSION_SUPPORT[permission];
63
+ const metadata = getPermissionSupport(permission);
64
64
  if (metadata.support === 'unsupported' || metadata.support === 'notImplemented') {
65
65
  return createUnavailableSupportState(permission);
66
66
  }
@@ -69,7 +69,7 @@ export function createPermissionClient(): PermissionClient {
69
69
  return createNotRequiredState(permission);
70
70
  }
71
71
 
72
- const adapter = adapters[permission];
72
+ const adapter = adapters.get(permission);
73
73
  if (!adapter) {
74
74
  throw new Error(`No adapter registered for permission: ${permission}`);
75
75
  }
@@ -91,6 +91,10 @@ export function createPermissionClient(): PermissionClient {
91
91
  };
92
92
  }
93
93
 
94
+ function getPermissionSupport(permission: Permission) {
95
+ return Reflect.get(EXPO_PERMISSION_SUPPORT, permission);
96
+ }
97
+
94
98
  function createUnavailableSupportState(permission: Permission): PermissionState {
95
99
  return createPermissionState({
96
100
  permission,
@@ -1,69 +1,67 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
 
3
- import { describe, expect, test } from 'bun:test';
3
+ import { expect, test } from 'bun:test';
4
4
 
5
5
  import { Permission, PERMISSIONS } from '../registry/permissions';
6
6
  import { EXPO_PERMISSION_CLIENT_REQUIRED_PACKAGES, EXPO_PERMISSION_SUPPORT } from './manifest';
7
7
 
8
- describe('EXPO_PERMISSION_SUPPORT', () => {
9
- test('defines metadata for every known permission', () => {
10
- expect(Object.keys(EXPO_PERMISSION_SUPPORT).sort()).toEqual([...PERMISSIONS].sort());
11
- });
12
-
13
- test('stays metadata-only and does not import Expo runtime modules', () => {
14
- const source = readFileSync(new URL('./manifest.ts', import.meta.url), 'utf8');
8
+ test('defines metadata for every known permission', () => {
9
+ expect(Object.keys(EXPO_PERMISSION_SUPPORT).sort()).toEqual([...PERMISSIONS].sort());
10
+ });
15
11
 
16
- expect(source).not.toContain("from 'expo-");
17
- expect(source).not.toContain('await import(');
18
- });
12
+ test('stays metadata-only and does not import Expo runtime modules', () => {
13
+ const source = readFileSync(new URL('./manifest.ts', import.meta.url), 'utf8');
19
14
 
20
- test('projects client-level settings recovery dependencies', () => {
21
- expect(EXPO_PERMISSION_CLIENT_REQUIRED_PACKAGES).toEqual(['expo-linking']);
22
- });
15
+ expect(source).not.toContain("from 'expo-");
16
+ expect(source).not.toContain('await import(');
17
+ });
23
18
 
24
- test('includes package and config hints for camera, microphone, and location permissions', () => {
25
- expect(EXPO_PERMISSION_SUPPORT[Permission.Camera]).toEqual({
26
- support: 'supported',
27
- requiredPackages: ['expo-camera'],
28
- configHints: ['cameraPermission'],
29
- });
19
+ test('projects client-level settings recovery dependencies', () => {
20
+ expect(EXPO_PERMISSION_CLIENT_REQUIRED_PACKAGES).toEqual(['expo-linking']);
21
+ });
30
22
 
31
- expect(EXPO_PERMISSION_SUPPORT[Permission.Microphone]).toEqual({
32
- support: 'supported',
33
- requiredPackages: ['expo-audio'],
34
- configHints: ['microphonePermission', 'recordAudioAndroid'],
35
- });
23
+ test('includes package and config hints for camera, microphone, and location permissions', () => {
24
+ expect(EXPO_PERMISSION_SUPPORT[Permission.Camera]).toEqual({
25
+ support: 'supported',
26
+ requiredPackages: ['expo-camera'],
27
+ configHints: ['cameraPermission'],
28
+ });
36
29
 
37
- expect(EXPO_PERMISSION_SUPPORT[Permission.LocationForeground]).toEqual({
38
- support: 'supported',
39
- requiredPackages: ['expo-location'],
40
- configHints: ['locationWhenInUsePermission'],
41
- });
30
+ expect(EXPO_PERMISSION_SUPPORT[Permission.Microphone]).toEqual({
31
+ support: 'supported',
32
+ requiredPackages: ['expo-audio'],
33
+ configHints: ['microphonePermission', 'recordAudioAndroid'],
34
+ });
42
35
 
43
- expect(EXPO_PERMISSION_SUPPORT[Permission.LocationBackground]).toEqual({
44
- support: 'supported',
45
- requiredPackages: ['expo-location'],
46
- configHints: ['locationAlwaysAndWhenInUsePermission'],
47
- });
36
+ expect(EXPO_PERMISSION_SUPPORT[Permission.LocationForeground]).toEqual({
37
+ support: 'supported',
38
+ requiredPackages: ['expo-location'],
39
+ configHints: ['locationWhenInUsePermission'],
48
40
  });
49
41
 
50
- test('treats clipboard as a not-required permission without runtime package requirements', () => {
51
- expect(EXPO_PERMISSION_SUPPORT[Permission.Clipboard]).toEqual({
52
- support: 'notRequired',
53
- requiredPackages: [],
54
- configHints: [],
55
- });
42
+ expect(EXPO_PERMISSION_SUPPORT[Permission.LocationBackground]).toEqual({
43
+ support: 'supported',
44
+ requiredPackages: ['expo-location'],
45
+ configHints: ['locationAlwaysAndWhenInUsePermission'],
56
46
  });
47
+ });
57
48
 
58
- test('keeps MediaLibraryWrite aligned with MediaLibrary metadata', () => {
59
- expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual({
60
- support: 'supported',
61
- requiredPackages: ['expo-media-library'],
62
- configHints: ['mediaLibraryPermission'],
63
- });
49
+ test('treats clipboard as a not-required permission without runtime package requirements', () => {
50
+ expect(EXPO_PERMISSION_SUPPORT[Permission.Clipboard]).toEqual({
51
+ support: 'notRequired',
52
+ requiredPackages: [],
53
+ configHints: [],
54
+ });
55
+ });
64
56
 
65
- expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual(
66
- EXPO_PERMISSION_SUPPORT[Permission.MediaLibrary],
67
- );
57
+ test('keeps MediaLibraryWrite aligned with MediaLibrary metadata', () => {
58
+ expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual({
59
+ support: 'supported',
60
+ requiredPackages: ['expo-media-library'],
61
+ configHints: ['mediaLibraryPermission'],
68
62
  });
63
+
64
+ expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual(
65
+ EXPO_PERMISSION_SUPPORT[Permission.MediaLibrary],
66
+ );
69
67
  });
@@ -1,11 +1,7 @@
1
1
  import { Permission } from '../registry/permissions';
2
2
 
3
3
  export type PermissionSupport =
4
- | 'supported'
5
- | 'notRequired'
6
- | 'limited'
7
- | 'notImplemented'
8
- | 'unsupported';
4
+ 'supported' | 'notRequired' | 'limited' | 'notImplemented' | 'unsupported';
9
5
 
10
6
  export interface ExpoPermissionMetadata {
11
7
  readonly support: PermissionSupport;
@@ -39,7 +39,7 @@ export function findExpoPermissionMethod(
39
39
  containerNames: readonly string[] = [],
40
40
  ): ExpoPermissionMethod | undefined {
41
41
  if (hasExpoPermissionMethod(source, methodName)) {
42
- return source[methodName];
42
+ return Reflect.get(source, methodName);
43
43
  }
44
44
 
45
45
  if (!isRecord(source)) {
@@ -47,10 +47,10 @@ export function findExpoPermissionMethod(
47
47
  }
48
48
 
49
49
  for (const containerName of containerNames) {
50
- const container = source[containerName];
50
+ const container = Reflect.get(source, containerName);
51
51
 
52
52
  if (hasExpoPermissionMethod(container, methodName)) {
53
- return container[methodName];
53
+ return Reflect.get(container, methodName);
54
54
  }
55
55
  }
56
56
 
@@ -93,7 +93,7 @@ function hasExpoPermissionMethod(
93
93
  value: unknown,
94
94
  methodName: string,
95
95
  ): value is Record<string, ExpoPermissionMethod> {
96
- return isRecord(value) && typeof value[methodName] === 'function';
96
+ return isRecord(value) && typeof Reflect.get(value, methodName) === 'function';
97
97
  }
98
98
 
99
99
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1,67 +1,65 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { expect, test } from 'bun:test';
2
2
 
3
3
  import { Permission } from '../registry/permissions';
4
4
  import { createFakePermissionClient } from '../testing/index';
5
5
  import { createPermissionManager } from './createPermissionManager';
6
6
 
7
- describe('createPermissionManager', () => {
8
- test('normalizes getStatus results', async () => {
9
- const manager = createPermissionManager(
10
- createFakePermissionClient({
11
- initialStates: [
12
- {
13
- permission: Permission.Camera,
14
- status: 'granted',
15
- },
16
- ],
17
- }),
18
- );
7
+ test('normalizes getStatus results', async () => {
8
+ const manager = createPermissionManager(
9
+ createFakePermissionClient({
10
+ initialStates: [
11
+ {
12
+ permission: Permission.Camera,
13
+ status: 'granted',
14
+ },
15
+ ],
16
+ }),
17
+ );
19
18
 
20
- const state = await manager.getStatus(Permission.Camera);
19
+ const state = await manager.getStatus(Permission.Camera);
21
20
 
22
- expect(state).toMatchObject({
23
- permission: Permission.Camera,
24
- status: 'granted',
25
- granted: true,
26
- });
21
+ expect(state).toMatchObject({
22
+ permission: Permission.Camera,
23
+ status: 'granted',
24
+ granted: true,
27
25
  });
26
+ });
28
27
 
29
- test('does not throw for normal denial', async () => {
30
- const manager = createPermissionManager(
31
- createFakePermissionClient({
32
- requestStates: [
33
- {
34
- permission: Permission.Notifications,
35
- status: 'denied',
36
- canAskAgain: false,
37
- },
38
- ],
39
- }),
40
- );
28
+ test('does not throw for normal denial', async () => {
29
+ const manager = createPermissionManager(
30
+ createFakePermissionClient({
31
+ requestStates: [
32
+ {
33
+ permission: Permission.Notifications,
34
+ status: 'denied',
35
+ canAskAgain: false,
36
+ },
37
+ ],
38
+ }),
39
+ );
41
40
 
42
- const state = await manager.request(Permission.Notifications);
41
+ const state = await manager.request(Permission.Notifications);
43
42
 
44
- expect(state).toMatchObject({
45
- status: 'denied',
46
- granted: false,
47
- canAskAgain: false,
48
- });
43
+ expect(state).toMatchObject({
44
+ status: 'denied',
45
+ granted: false,
46
+ canAskAgain: false,
49
47
  });
48
+ });
50
49
 
51
- test('exposes openSettings only when the client supports it', async () => {
52
- let opened = false;
53
- const manager = createPermissionManager(
54
- createFakePermissionClient({
55
- openSettings: () => {
56
- opened = true;
50
+ test('exposes openSettings only when the client supports it', async () => {
51
+ let opened = false;
52
+ const manager = createPermissionManager(
53
+ createFakePermissionClient({
54
+ openSettings: () => {
55
+ opened = true;
57
56
 
58
- return Promise.resolve();
59
- },
60
- }),
61
- );
57
+ return Promise.resolve();
58
+ },
59
+ }),
60
+ );
62
61
 
63
- await manager.openSettings?.();
62
+ await manager.openSettings?.();
64
63
 
65
- expect(opened).toBe(true);
66
- });
64
+ expect(opened).toBe(true);
67
65
  });
@@ -105,15 +105,8 @@ export function usePermission(
105
105
  options: UsePermissionOptions = {},
106
106
  ): PermissionHookResult {
107
107
  const manager = usePermissions();
108
- const [state, setState] = useState<PermissionState>(
109
- () =>
110
- (options.initialState === undefined
111
- ? undefined
112
- : normalizePermissionState(permission, options.initialState)) ??
113
- createPermissionState({
114
- permission,
115
- status: 'unknown',
116
- }),
108
+ const [state, setState] = useState<PermissionState>(() =>
109
+ createInitialPermissionState(permission, options.initialState),
117
110
  );
118
111
 
119
112
  const refresh = useCallback(async () => {
@@ -131,10 +124,15 @@ export function usePermission(
131
124
  }, [manager, permission]);
132
125
 
133
126
  useEffect(() => {
134
- if (options.refreshOnMount === true) {
135
- void refresh();
136
- }
137
- }, [options.refreshOnMount, refresh]);
127
+ if (options.refreshOnMount !== true) return;
128
+ let active = true;
129
+ void manager.getStatus(permission).then((nextState) => {
130
+ if (active) setState(nextState);
131
+ });
132
+ return () => {
133
+ active = false;
134
+ };
135
+ }, [manager, options.refreshOnMount, permission]);
138
136
 
139
137
  const openSettings =
140
138
  manager.openSettings === undefined
@@ -154,3 +152,13 @@ export function usePermission(
154
152
  openSettings,
155
153
  };
156
154
  }
155
+
156
+ function createInitialPermissionState(
157
+ permission: Permission,
158
+ initialState: PermissionState | undefined,
159
+ ): PermissionState {
160
+ return (
161
+ (initialState === undefined ? undefined : normalizePermissionState(permission, initialState)) ??
162
+ createPermissionState({ permission, status: 'unknown' })
163
+ );
164
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
 
3
+ import packageJson from '../../package.json';
3
4
  import {
4
5
  getPermissionDefinition,
5
6
  isPermission,
@@ -24,12 +25,23 @@ describe('permission registry', () => {
24
25
 
25
26
  test('exposes metadata for every permission', () => {
26
27
  for (const permission of PERMISSIONS) {
27
- expect(PERMISSION_REGISTRY[permission].permission).toBe(permission);
28
- expect(PERMISSION_REGISTRY[permission].label.length).toBeGreaterThan(0);
29
- expect(getPermissionDefinition(permission)).toEqual(PERMISSION_REGISTRY[permission]);
28
+ const definition = Reflect.get(PERMISSION_REGISTRY, permission);
29
+ expect(definition.permission).toBe(permission);
30
+ expect(definition.label.length).toBeGreaterThan(0);
31
+ expect(getPermissionDefinition(permission)).toEqual(definition);
30
32
  }
31
33
  });
32
34
 
35
+ test('publishes the registry as a dedicated headless subpath', () => {
36
+ expect(packageJson.exports['./registry']).toEqual({
37
+ 'react-native': './dist/registry/permissions.js',
38
+ browser: './dist/registry/permissions.js',
39
+ types: './dist/registry/permissions.d.ts',
40
+ import: './dist/registry/permissions.js',
41
+ default: './dist/registry/permissions.js',
42
+ });
43
+ });
44
+
33
45
  test('validates permission strings', () => {
34
46
  expect(isPermission('camera')).toBe(true);
35
47
  expect(isPermission('not-a-permission')).toBe(false);
@@ -7,12 +7,7 @@ import type { Permission } from '../registry/permissions';
7
7
  * but user denial should be represented as a permission state.
8
8
  */
9
9
  export type PermissionStatus =
10
- | 'unknown'
11
- | 'granted'
12
- | 'denied'
13
- | 'blocked'
14
- | 'limited'
15
- | 'unavailable';
10
+ 'unknown' | 'granted' | 'denied' | 'blocked' | 'limited' | 'unavailable';
16
11
 
17
12
  /***
18
13
  * Stable ordered list of normalized statuses.