@ankhorage/permissions 0.2.2 → 0.2.4

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 (66) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +27 -3
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.d.ts.map +1 -0
  5. package/dist/cli/index.js +2 -0
  6. package/dist/cli/index.js.map +1 -0
  7. package/dist/expo/adapters/mediaLibrary.d.ts.map +1 -1
  8. package/dist/expo/adapters/mediaLibrary.js +15 -3
  9. package/dist/expo/adapters/mediaLibrary.js.map +1 -1
  10. package/dist/expo/adapters/notifications.d.ts.map +1 -1
  11. package/dist/expo/adapters/notifications.js +32 -3
  12. package/dist/expo/adapters/notifications.js.map +1 -1
  13. package/dist/expo/client.d.ts +14 -0
  14. package/dist/expo/client.d.ts.map +1 -1
  15. package/dist/expo/client.js +42 -13
  16. package/dist/expo/client.js.map +1 -1
  17. package/dist/expo/client.testSupport.d.ts +54 -0
  18. package/dist/expo/client.testSupport.d.ts.map +1 -0
  19. package/dist/expo/client.testSupport.js +183 -0
  20. package/dist/expo/client.testSupport.js.map +1 -0
  21. package/dist/expo/manifest.d.ts +5 -0
  22. package/dist/expo/manifest.d.ts.map +1 -1
  23. package/dist/expo/manifest.js +5 -0
  24. package/dist/expo/manifest.js.map +1 -1
  25. package/dist/expo/permissionResponse.d.ts.map +1 -1
  26. package/dist/expo/permissionResponse.js +16 -9
  27. package/dist/expo/permissionResponse.js.map +1 -1
  28. package/dist/react/index.d.ts.map +1 -1
  29. package/dist/react/index.js +16 -11
  30. package/dist/react/index.js.map +1 -1
  31. package/dist/state/permissionState.d.ts +2 -0
  32. package/dist/state/permissionState.d.ts.map +1 -1
  33. package/dist/state/permissionState.js +3 -1
  34. package/dist/state/permissionState.js.map +1 -1
  35. package/dist/testing/index.d.ts.map +1 -1
  36. package/dist/testing/index.js +27 -17
  37. package/dist/testing/index.js.map +1 -1
  38. package/dist/web/index.d.ts +1 -2
  39. package/dist/web/index.d.ts.map +1 -1
  40. package/dist/web/index.js +2 -76
  41. package/dist/web/index.js.map +1 -1
  42. package/dist/web/permissionStates.d.ts +10 -0
  43. package/dist/web/permissionStates.d.ts.map +1 -0
  44. package/dist/web/permissionStates.js +64 -0
  45. package/dist/web/permissionStates.js.map +1 -0
  46. package/package.json +22 -13
  47. package/src/cli/index.ts +1 -0
  48. package/src/expo/adapters/mediaLibrary.ts +28 -4
  49. package/src/expo/adapters/notifications.ts +73 -4
  50. package/src/expo/client.notifications.test.ts +106 -0
  51. package/src/expo/client.test.ts +109 -349
  52. package/src/expo/client.testSupport.ts +188 -0
  53. package/src/expo/client.ts +45 -13
  54. package/src/expo/manifest.test.ts +49 -47
  55. package/src/expo/manifest.ts +7 -5
  56. package/src/expo/modules.d.ts +18 -4
  57. package/src/expo/permissionResponse.ts +26 -9
  58. package/src/manager/createPermissionManager.test.ts +48 -50
  59. package/src/react/index.tsx +21 -13
  60. package/src/registry/permissions.test.ts +4 -3
  61. package/src/state/permissionState.test.ts +8 -1
  62. package/src/state/permissionState.ts +4 -7
  63. package/src/testing/index.ts +36 -25
  64. package/src/web/index.test.ts +149 -151
  65. package/src/web/index.ts +9 -102
  66. package/src/web/permissionStates.ts +83 -0
@@ -8,25 +8,41 @@ import { microphoneAdapter } from './adapters/microphone';
8
8
  import { notificationsAdapter } from './adapters/notifications';
9
9
  import { EXPO_PERMISSION_SUPPORT } from './manifest';
10
10
 
11
+ const OPEN_SETTINGS_ERROR_MESSAGE = 'Unable to open application settings.';
12
+
11
13
  export interface ExpoPermissionAdapter {
12
14
  getStatus(permission: Permission): Promise<PermissionState>;
13
15
  request(permission: Permission): Promise<PermissionState>;
14
16
  }
15
17
 
16
- const adapters: Partial<Record<Permission, ExpoPermissionAdapter>> = {
17
- [Permission.Camera]: cameraAdapter,
18
- [Permission.MediaLibrary]: mediaLibraryAdapter,
19
- [Permission.MediaLibraryWrite]: mediaLibraryAdapter,
20
- [Permission.Microphone]: microphoneAdapter,
21
- [Permission.LocationForeground]: locationAdapter,
22
- [Permission.LocationBackground]: locationAdapter,
23
- [Permission.Notifications]: notificationsAdapter,
24
- };
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
+ ]);
25
27
 
28
+ /***
29
+ * Creates a permission client backed by Expo SDK modules.
30
+ *
31
+ * Denials that Expo cannot request again are normalized to `blocked`. Limited
32
+ * media-library access and provisional or ephemeral iOS notification access
33
+ * are normalized to usable `limited` states, where `granted` remains true.
34
+ * The client requires `expo-linking` for settings recovery and rejects with a
35
+ * stable error when the operating-system settings page cannot be opened.
36
+ *
37
+ * Expo modules are loaded only when a client operation uses them. The package
38
+ * root and Expo manifest metadata remain free of native module imports.
39
+ *
40
+ * @readme
41
+ */
26
42
  export function createPermissionClient(): PermissionClient {
27
43
  return {
28
44
  async getStatus(permission: Permission): Promise<PermissionState> {
29
- const metadata = EXPO_PERMISSION_SUPPORT[permission];
45
+ const metadata = getPermissionSupport(permission);
30
46
  if (metadata.support === 'unsupported' || metadata.support === 'notImplemented') {
31
47
  return createUnavailableSupportState(permission);
32
48
  }
@@ -35,7 +51,7 @@ export function createPermissionClient(): PermissionClient {
35
51
  return createNotRequiredState(permission);
36
52
  }
37
53
 
38
- const adapter = adapters[permission];
54
+ const adapter = adapters.get(permission);
39
55
  if (!adapter) {
40
56
  throw new Error(`No adapter registered for permission: ${permission}`);
41
57
  }
@@ -44,7 +60,7 @@ export function createPermissionClient(): PermissionClient {
44
60
  },
45
61
 
46
62
  async request(permission: Permission): Promise<PermissionState> {
47
- const metadata = EXPO_PERMISSION_SUPPORT[permission];
63
+ const metadata = getPermissionSupport(permission);
48
64
  if (metadata.support === 'unsupported' || metadata.support === 'notImplemented') {
49
65
  return createUnavailableSupportState(permission);
50
66
  }
@@ -53,16 +69,32 @@ export function createPermissionClient(): PermissionClient {
53
69
  return createNotRequiredState(permission);
54
70
  }
55
71
 
56
- const adapter = adapters[permission];
72
+ const adapter = adapters.get(permission);
57
73
  if (!adapter) {
58
74
  throw new Error(`No adapter registered for permission: ${permission}`);
59
75
  }
60
76
 
61
77
  return adapter.request(permission);
62
78
  },
79
+
80
+ async openSettings(): Promise<void> {
81
+ try {
82
+ const linking = await import('expo-linking');
83
+
84
+ await linking.openSettings();
85
+ } catch (error) {
86
+ throw new Error(OPEN_SETTINGS_ERROR_MESSAGE, {
87
+ cause: error,
88
+ });
89
+ }
90
+ },
63
91
  };
64
92
  }
65
93
 
94
+ function getPermissionSupport(permission: Permission) {
95
+ return Reflect.get(EXPO_PERMISSION_SUPPORT, permission);
96
+ }
97
+
66
98
  function createUnavailableSupportState(permission: Permission): PermissionState {
67
99
  return createPermissionState({
68
100
  permission,
@@ -1,65 +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
- import { EXPO_PERMISSION_SUPPORT } from './manifest';
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
- });
8
+ test('defines metadata for every known permission', () => {
9
+ expect(Object.keys(EXPO_PERMISSION_SUPPORT).sort()).toEqual([...PERMISSIONS].sort());
10
+ });
12
11
 
13
- test('stays metadata-only and does not import Expo runtime modules', () => {
14
- const source = readFileSync(new URL('./manifest.ts', import.meta.url), 'utf8');
12
+ test('stays metadata-only and does not import Expo runtime modules', () => {
13
+ const source = readFileSync(new URL('./manifest.ts', import.meta.url), 'utf8');
15
14
 
16
- expect(source).not.toContain("from 'expo-");
17
- expect(source).not.toContain('await import(');
18
- });
15
+ expect(source).not.toContain("from 'expo-");
16
+ expect(source).not.toContain('await import(');
17
+ });
19
18
 
20
- test('includes package and config hints for camera, microphone, and location permissions', () => {
21
- expect(EXPO_PERMISSION_SUPPORT[Permission.Camera]).toEqual({
22
- support: 'supported',
23
- requiredPackages: ['expo-camera'],
24
- configHints: ['cameraPermission'],
25
- });
19
+ test('projects client-level settings recovery dependencies', () => {
20
+ expect(EXPO_PERMISSION_CLIENT_REQUIRED_PACKAGES).toEqual(['expo-linking']);
21
+ });
26
22
 
27
- expect(EXPO_PERMISSION_SUPPORT[Permission.Microphone]).toEqual({
28
- support: 'supported',
29
- requiredPackages: ['expo-audio'],
30
- configHints: ['microphonePermission', 'recordAudioAndroid'],
31
- });
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
+ });
32
29
 
33
- expect(EXPO_PERMISSION_SUPPORT[Permission.LocationForeground]).toEqual({
34
- support: 'supported',
35
- requiredPackages: ['expo-location'],
36
- configHints: ['locationWhenInUsePermission'],
37
- });
30
+ expect(EXPO_PERMISSION_SUPPORT[Permission.Microphone]).toEqual({
31
+ support: 'supported',
32
+ requiredPackages: ['expo-audio'],
33
+ configHints: ['microphonePermission', 'recordAudioAndroid'],
34
+ });
38
35
 
39
- expect(EXPO_PERMISSION_SUPPORT[Permission.LocationBackground]).toEqual({
40
- support: 'supported',
41
- requiredPackages: ['expo-location'],
42
- configHints: ['locationAlwaysAndWhenInUsePermission'],
43
- });
36
+ expect(EXPO_PERMISSION_SUPPORT[Permission.LocationForeground]).toEqual({
37
+ support: 'supported',
38
+ requiredPackages: ['expo-location'],
39
+ configHints: ['locationWhenInUsePermission'],
44
40
  });
45
41
 
46
- test('treats clipboard as a not-required permission without runtime package requirements', () => {
47
- expect(EXPO_PERMISSION_SUPPORT[Permission.Clipboard]).toEqual({
48
- support: 'notRequired',
49
- requiredPackages: [],
50
- configHints: [],
51
- });
42
+ expect(EXPO_PERMISSION_SUPPORT[Permission.LocationBackground]).toEqual({
43
+ support: 'supported',
44
+ requiredPackages: ['expo-location'],
45
+ configHints: ['locationAlwaysAndWhenInUsePermission'],
52
46
  });
47
+ });
53
48
 
54
- test('keeps MediaLibraryWrite aligned with MediaLibrary metadata', () => {
55
- expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual({
56
- support: 'supported',
57
- requiredPackages: ['expo-media-library'],
58
- configHints: ['mediaLibraryPermission'],
59
- });
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
+ });
60
56
 
61
- expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual(
62
- EXPO_PERMISSION_SUPPORT[Permission.MediaLibrary],
63
- );
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'],
64
62
  });
63
+
64
+ expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual(
65
+ EXPO_PERMISSION_SUPPORT[Permission.MediaLibrary],
66
+ );
65
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;
@@ -13,6 +9,12 @@ export interface ExpoPermissionMetadata {
13
9
  readonly configHints: readonly string[];
14
10
  }
15
11
 
12
+ /***
13
+ * Expo packages required by the permission client independently of a specific
14
+ * permission capability.
15
+ */
16
+ export const EXPO_PERMISSION_CLIENT_REQUIRED_PACKAGES = ['expo-linking'] as const;
17
+
16
18
  export const EXPO_PERMISSION_SUPPORT: Readonly<Record<Permission, ExpoPermissionMetadata>> = {
17
19
  [Permission.Camera]: {
18
20
  support: 'supported',
@@ -3,6 +3,20 @@ interface ExpoPermissionResult {
3
3
  canAskAgain?: boolean;
4
4
  }
5
5
 
6
+ interface ExpoMediaLibraryPermissionResult extends ExpoPermissionResult {
7
+ accessPrivileges?: 'all' | 'limited' | 'none';
8
+ }
9
+
10
+ interface ExpoNotificationPermissionResult extends ExpoPermissionResult {
11
+ ios?: {
12
+ status?: number;
13
+ };
14
+ }
15
+
16
+ declare module 'expo-linking' {
17
+ export function openSettings(): Promise<void>;
18
+ }
19
+
6
20
  declare module 'expo-camera' {
7
21
  export function getCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
8
22
  export function requestCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
@@ -27,11 +41,11 @@ declare module 'expo-media-library' {
27
41
  export function getPermissionsAsync(
28
42
  writeOnly?: boolean,
29
43
  granularPermissions?: readonly string[],
30
- ): Promise<ExpoPermissionResult>;
44
+ ): Promise<ExpoMediaLibraryPermissionResult>;
31
45
  export function requestPermissionsAsync(
32
46
  writeOnly?: boolean,
33
47
  granularPermissions?: readonly string[],
34
- ): Promise<ExpoPermissionResult>;
48
+ ): Promise<ExpoMediaLibraryPermissionResult>;
35
49
  }
36
50
 
37
51
  declare module 'expo-audio' {
@@ -48,6 +62,6 @@ declare module 'expo-audio' {
48
62
  }
49
63
 
50
64
  declare module 'expo-notifications' {
51
- export function getPermissionsAsync(): Promise<ExpoPermissionResult>;
52
- export function requestPermissionsAsync(): Promise<ExpoPermissionResult>;
65
+ export function getPermissionsAsync(): Promise<ExpoNotificationPermissionResult>;
66
+ export function requestPermissionsAsync(): Promise<ExpoNotificationPermissionResult>;
53
67
  }
@@ -3,6 +3,7 @@ import {
3
3
  createPermissionState,
4
4
  isPermissionStatus,
5
5
  type PermissionState,
6
+ type PermissionStatus,
6
7
  } from '../state/permissionState';
7
8
 
8
9
  export interface ExpoPermissionResponse {
@@ -18,11 +19,7 @@ export function createExpoPermissionState(
18
19
  ): PermissionState {
19
20
  const status = isPermissionStatus(response.status) ? response.status : 'unknown';
20
21
 
21
- return createPermissionState({
22
- permission,
23
- status,
24
- canAskAgain: response.canAskAgain,
25
- });
22
+ return createNormalizedExpoPermissionState(permission, response, status);
26
23
  }
27
24
 
28
25
  export function createExpoUnavailableState(
@@ -42,7 +39,7 @@ export function findExpoPermissionMethod(
42
39
  containerNames: readonly string[] = [],
43
40
  ): ExpoPermissionMethod | undefined {
44
41
  if (hasExpoPermissionMethod(source, methodName)) {
45
- return source[methodName];
42
+ return Reflect.get(source, methodName);
46
43
  }
47
44
 
48
45
  if (!isRecord(source)) {
@@ -50,10 +47,10 @@ export function findExpoPermissionMethod(
50
47
  }
51
48
 
52
49
  for (const containerName of containerNames) {
53
- const container = source[containerName];
50
+ const container = Reflect.get(source, containerName);
54
51
 
55
52
  if (hasExpoPermissionMethod(container, methodName)) {
56
- return container[methodName];
53
+ return Reflect.get(container, methodName);
57
54
  }
58
55
  }
59
56
 
@@ -72,11 +69,31 @@ function getExpoPermissionErrorMessage(error: unknown): string {
72
69
  return 'Expo permission operation failed.';
73
70
  }
74
71
 
72
+ function createNormalizedExpoPermissionState(
73
+ permission: Permission,
74
+ response: ExpoPermissionResponse,
75
+ status: PermissionStatus,
76
+ reason?: string,
77
+ ): PermissionState {
78
+ const normalizedStatus =
79
+ status === 'denied' && response.canAskAgain === false ? 'blocked' : status;
80
+
81
+ return createPermissionState({
82
+ permission,
83
+ status: normalizedStatus,
84
+ canAskAgain: response.canAskAgain,
85
+ reason:
86
+ normalizedStatus === 'blocked'
87
+ ? 'Permission is blocked. Open system settings to change it.'
88
+ : reason,
89
+ });
90
+ }
91
+
75
92
  function hasExpoPermissionMethod(
76
93
  value: unknown,
77
94
  methodName: string,
78
95
  ): value is Record<string, ExpoPermissionMethod> {
79
- return isRecord(value) && typeof value[methodName] === 'function';
96
+ return isRecord(value) && typeof Reflect.get(value, methodName) === 'function';
80
97
  }
81
98
 
82
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
+ }
@@ -24,9 +24,10 @@ describe('permission registry', () => {
24
24
 
25
25
  test('exposes metadata for every permission', () => {
26
26
  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]);
27
+ const definition = Reflect.get(PERMISSION_REGISTRY, permission);
28
+ expect(definition.permission).toBe(permission);
29
+ expect(definition.label.length).toBeGreaterThan(0);
30
+ expect(getPermissionDefinition(permission)).toEqual(definition);
30
31
  }
31
32
  });
32
33
 
@@ -25,6 +25,13 @@ describe('permission state', () => {
25
25
  }).granted,
26
26
  ).toBe(true);
27
27
 
28
+ expect(
29
+ createPermissionState({
30
+ permission: Permission.MediaLibrary,
31
+ status: 'limited',
32
+ }).granted,
33
+ ).toBe(true);
34
+
28
35
  expect(
29
36
  createPermissionState({
30
37
  permission: Permission.Camera,
@@ -45,7 +52,7 @@ describe('permission state', () => {
45
52
  expect(state).toEqual({
46
53
  permission: Permission.Camera,
47
54
  status: 'limited',
48
- granted: false,
55
+ granted: true,
49
56
  canAskAgain: true,
50
57
  requestedAt: undefined,
51
58
  reason: 'library-limited',
@@ -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.
@@ -57,13 +52,15 @@ export function isPermissionStatus(value: string): value is PermissionStatus {
57
52
 
58
53
  /***
59
54
  * Creates a normalized state and derives the `granted` convenience flag.
55
+ * Limited access is usable partial authorization, so both `granted` and
56
+ * `limited` states set `granted` to true.
60
57
  */
61
58
  export function createPermissionState(input: CreatePermissionStateInput): PermissionState {
62
59
  return {
63
60
  permission: input.permission,
64
61
  status: input.status,
65
62
  canAskAgain: input.canAskAgain,
66
- granted: input.status === 'granted',
63
+ granted: input.status === 'granted' || input.status === 'limited',
67
64
  requestedAt: input.requestedAt,
68
65
  reason: input.reason,
69
66
  };