@ankhorage/permissions 0.1.1 → 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.
- package/CHANGELOG.md +12 -0
- package/README.md +98 -22
- package/dist/ankh.provider.d.ts +40 -0
- package/dist/ankh.provider.d.ts.map +1 -0
- package/dist/ankh.provider.js +55 -0
- package/dist/ankh.provider.js.map +1 -0
- package/dist/client/types.d.ts +2 -8
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/expo/adapters/camera.d.ts +3 -0
- package/dist/expo/adapters/camera.d.ts.map +1 -0
- package/dist/expo/adapters/camera.js +30 -0
- package/dist/expo/adapters/camera.js.map +1 -0
- package/dist/expo/adapters/location.d.ts +3 -0
- package/dist/expo/adapters/location.d.ts.map +1 -0
- package/dist/expo/adapters/location.js +31 -0
- package/dist/expo/adapters/location.js.map +1 -0
- package/dist/expo/adapters/mediaLibrary.d.ts +3 -0
- package/dist/expo/adapters/mediaLibrary.d.ts.map +1 -0
- package/dist/expo/adapters/mediaLibrary.js +28 -0
- package/dist/expo/adapters/mediaLibrary.js.map +1 -0
- package/dist/expo/adapters/microphone.d.ts +3 -0
- package/dist/expo/adapters/microphone.d.ts.map +1 -0
- package/dist/expo/adapters/microphone.js +30 -0
- package/dist/expo/adapters/microphone.js.map +1 -0
- package/dist/expo/adapters/notifications.d.ts +3 -0
- package/dist/expo/adapters/notifications.d.ts.map +1 -0
- package/dist/expo/adapters/notifications.js +24 -0
- package/dist/expo/adapters/notifications.js.map +1 -0
- package/dist/expo/client.d.ts +9 -0
- package/dist/expo/client.d.ts.map +1 -0
- package/dist/expo/client.js +63 -0
- package/dist/expo/client.js.map +1 -0
- package/dist/expo/index.d.ts +3 -0
- package/dist/expo/index.d.ts.map +1 -0
- package/dist/expo/index.js +3 -0
- package/dist/expo/index.js.map +1 -0
- package/dist/expo/manifest.d.ts +9 -0
- package/dist/expo/manifest.d.ts.map +1 -0
- package/dist/expo/manifest.js +44 -0
- package/dist/expo/manifest.js.map +1 -0
- package/dist/expo/permissionResponse.d.ts +11 -0
- package/dist/expo/permissionResponse.d.ts.map +1 -0
- package/dist/expo/permissionResponse.js +47 -0
- package/dist/expo/permissionResponse.js.map +1 -0
- package/dist/manager/createPermissionManager.d.ts +4 -15
- package/dist/manager/createPermissionManager.d.ts.map +1 -1
- package/dist/manager/createPermissionManager.js +3 -14
- package/dist/manager/createPermissionManager.js.map +1 -1
- package/dist/react/index.d.ts +0 -14
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +0 -14
- package/dist/react/index.js.map +1 -1
- package/dist/testing/index.d.ts +0 -8
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +0 -8
- package/dist/testing/index.js.map +1 -1
- package/package.json +24 -2
- package/src/ankh.provider.ts +66 -0
- package/src/client/types.ts +2 -8
- package/src/expo/adapters/camera.ts +52 -0
- package/src/expo/adapters/location.ts +34 -0
- package/src/expo/adapters/mediaLibrary.ts +33 -0
- package/src/expo/adapters/microphone.ts +52 -0
- package/src/expo/adapters/notifications.ts +28 -0
- package/src/expo/client.test.ts +372 -0
- package/src/expo/client.ts +79 -0
- package/src/expo/index.ts +3 -0
- package/src/expo/manifest.test.ts +65 -0
- package/src/expo/manifest.ts +57 -0
- package/src/expo/modules.d.ts +53 -0
- package/src/expo/permissionResponse.ts +84 -0
- package/src/manager/createPermissionManager.ts +4 -15
- package/src/provider.test.ts +13 -0
- package/src/react/index.tsx +0 -14
- package/src/testing/index.ts +0 -8
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Permission } from '../../registry/permissions';
|
|
2
|
+
import type { PermissionState } from '../../state/permissionState';
|
|
3
|
+
import type { ExpoPermissionAdapter } from '../client';
|
|
4
|
+
import { createExpoPermissionState, createExpoUnavailableState } from '../permissionResponse';
|
|
5
|
+
|
|
6
|
+
export const locationAdapter: ExpoPermissionAdapter = {
|
|
7
|
+
async getStatus(permission: Permission): Promise<PermissionState> {
|
|
8
|
+
try {
|
|
9
|
+
const mod = await import('expo-location');
|
|
10
|
+
const isBackground = permission === Permission.LocationBackground;
|
|
11
|
+
const result = isBackground
|
|
12
|
+
? await mod.getBackgroundPermissionsAsync()
|
|
13
|
+
: await mod.getForegroundPermissionsAsync();
|
|
14
|
+
|
|
15
|
+
return createExpoPermissionState(permission, result);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return createExpoUnavailableState(permission, error);
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async request(permission: Permission): Promise<PermissionState> {
|
|
22
|
+
try {
|
|
23
|
+
const mod = await import('expo-location');
|
|
24
|
+
const isBackground = permission === Permission.LocationBackground;
|
|
25
|
+
const result = isBackground
|
|
26
|
+
? await mod.requestBackgroundPermissionsAsync()
|
|
27
|
+
: await mod.requestForegroundPermissionsAsync();
|
|
28
|
+
|
|
29
|
+
return createExpoPermissionState(permission, result);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return createExpoUnavailableState(permission, error);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Permission } from '../../registry/permissions';
|
|
2
|
+
import { Permission as KnownPermission } from '../../registry/permissions';
|
|
3
|
+
import type { PermissionState } from '../../state/permissionState';
|
|
4
|
+
import type { ExpoPermissionAdapter } from '../client';
|
|
5
|
+
import { createExpoPermissionState, createExpoUnavailableState } from '../permissionResponse';
|
|
6
|
+
|
|
7
|
+
export const mediaLibraryAdapter: ExpoPermissionAdapter = {
|
|
8
|
+
async getStatus(permission: Permission): Promise<PermissionState> {
|
|
9
|
+
try {
|
|
10
|
+
const mod = await import('expo-media-library');
|
|
11
|
+
const result = await mod.getPermissionsAsync(isWriteOnlyPermission(permission));
|
|
12
|
+
|
|
13
|
+
return createExpoPermissionState(permission, result);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return createExpoUnavailableState(permission, error);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
async request(permission: Permission): Promise<PermissionState> {
|
|
20
|
+
try {
|
|
21
|
+
const mod = await import('expo-media-library');
|
|
22
|
+
const result = await mod.requestPermissionsAsync(isWriteOnlyPermission(permission));
|
|
23
|
+
|
|
24
|
+
return createExpoPermissionState(permission, result);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return createExpoUnavailableState(permission, error);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function isWriteOnlyPermission(permission: Permission): boolean {
|
|
32
|
+
return permission === KnownPermission.MediaLibraryWrite;
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Permission } from '../../registry/permissions';
|
|
2
|
+
import type { PermissionState } from '../../state/permissionState';
|
|
3
|
+
import type { ExpoPermissionAdapter } from '../client';
|
|
4
|
+
import {
|
|
5
|
+
createExpoPermissionState,
|
|
6
|
+
createExpoUnavailableState,
|
|
7
|
+
type ExpoPermissionMethod,
|
|
8
|
+
findExpoPermissionMethod,
|
|
9
|
+
} from '../permissionResponse';
|
|
10
|
+
|
|
11
|
+
const AUDIO_PERMISSION_CONTAINER_NAMES = ['AudioModule', 'Audio'] as const;
|
|
12
|
+
|
|
13
|
+
export const microphoneAdapter: ExpoPermissionAdapter = {
|
|
14
|
+
async getStatus(permission: Permission): Promise<PermissionState> {
|
|
15
|
+
try {
|
|
16
|
+
const result = await resolveAudioPermissionMethod(
|
|
17
|
+
await import('expo-audio'),
|
|
18
|
+
'getRecordingPermissionsAsync',
|
|
19
|
+
)();
|
|
20
|
+
|
|
21
|
+
return createExpoPermissionState(permission, result);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return createExpoUnavailableState(permission, error);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async request(permission: Permission): Promise<PermissionState> {
|
|
28
|
+
try {
|
|
29
|
+
const result = await resolveAudioPermissionMethod(
|
|
30
|
+
await import('expo-audio'),
|
|
31
|
+
'requestRecordingPermissionsAsync',
|
|
32
|
+
)();
|
|
33
|
+
|
|
34
|
+
return createExpoPermissionState(permission, result);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return createExpoUnavailableState(permission, error);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function resolveAudioPermissionMethod(
|
|
42
|
+
source: unknown,
|
|
43
|
+
methodName: 'getRecordingPermissionsAsync' | 'requestRecordingPermissionsAsync',
|
|
44
|
+
): ExpoPermissionMethod {
|
|
45
|
+
const method = findExpoPermissionMethod(source, methodName, AUDIO_PERMISSION_CONTAINER_NAMES);
|
|
46
|
+
|
|
47
|
+
if (method === undefined) {
|
|
48
|
+
throw new Error(`expo-audio does not export ${methodName}.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return method;
|
|
52
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Permission } from '../../registry/permissions';
|
|
2
|
+
import type { PermissionState } from '../../state/permissionState';
|
|
3
|
+
import type { ExpoPermissionAdapter } from '../client';
|
|
4
|
+
import { createExpoPermissionState, createExpoUnavailableState } from '../permissionResponse';
|
|
5
|
+
|
|
6
|
+
export const notificationsAdapter: ExpoPermissionAdapter = {
|
|
7
|
+
async getStatus(permission: Permission): Promise<PermissionState> {
|
|
8
|
+
try {
|
|
9
|
+
const mod = await import('expo-notifications');
|
|
10
|
+
const result = await mod.getPermissionsAsync();
|
|
11
|
+
|
|
12
|
+
return createExpoPermissionState(permission, result);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return createExpoUnavailableState(permission, error);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
async request(permission: Permission): Promise<PermissionState> {
|
|
19
|
+
try {
|
|
20
|
+
const mod = await import('expo-notifications');
|
|
21
|
+
const result = await mod.requestPermissionsAsync();
|
|
22
|
+
|
|
23
|
+
return createExpoPermissionState(permission, result);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return createExpoUnavailableState(permission, error);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { Permission, PERMISSIONS } from '../registry/permissions';
|
|
4
|
+
import { createPermissionClient } from './client';
|
|
5
|
+
|
|
6
|
+
interface MockPermissionResponse {
|
|
7
|
+
readonly status: string;
|
|
8
|
+
readonly canAskAgain?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type CameraMockMode = 'topLevel' | 'cameraNamespace' | 'missingExports';
|
|
12
|
+
type AudioMockMode = 'topLevel' | 'audioModule' | 'missingExports';
|
|
13
|
+
|
|
14
|
+
function permissionResponse(status: string, canAskAgain = true): MockPermissionResponse {
|
|
15
|
+
return { status, canAskAgain };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cameraMock = {
|
|
19
|
+
mode: 'topLevel' as CameraMockMode,
|
|
20
|
+
getResponse: permissionResponse('granted'),
|
|
21
|
+
requestResponse: permissionResponse('granted'),
|
|
22
|
+
getError: undefined as string | undefined,
|
|
23
|
+
requestError: undefined as string | undefined,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const audioMock = {
|
|
27
|
+
mode: 'topLevel' as AudioMockMode,
|
|
28
|
+
getResponse: permissionResponse('granted'),
|
|
29
|
+
requestResponse: permissionResponse('granted'),
|
|
30
|
+
getError: undefined as string | undefined,
|
|
31
|
+
requestError: undefined as string | undefined,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const mediaLibraryMock = {
|
|
35
|
+
getResponse: permissionResponse('granted'),
|
|
36
|
+
requestResponse: permissionResponse('granted'),
|
|
37
|
+
getError: undefined as string | undefined,
|
|
38
|
+
requestError: undefined as string | undefined,
|
|
39
|
+
getCalls: [] as boolean[],
|
|
40
|
+
requestCalls: [] as boolean[],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const locationMock = {
|
|
44
|
+
foregroundGetResponse: permissionResponse('granted'),
|
|
45
|
+
foregroundRequestResponse: permissionResponse('granted'),
|
|
46
|
+
backgroundGetResponse: permissionResponse('granted'),
|
|
47
|
+
backgroundRequestResponse: permissionResponse('granted'),
|
|
48
|
+
getCalls: [] as string[],
|
|
49
|
+
requestCalls: [] as string[],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const notificationsMock = {
|
|
53
|
+
getResponse: permissionResponse('granted'),
|
|
54
|
+
requestResponse: permissionResponse('granted'),
|
|
55
|
+
getError: undefined as string | undefined,
|
|
56
|
+
requestError: undefined as string | undefined,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
void mock.module('expo-camera', () => ({
|
|
60
|
+
get getCameraPermissionsAsync() {
|
|
61
|
+
if (cameraMock.mode !== 'topLevel') {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
if (cameraMock.getError !== undefined) {
|
|
67
|
+
throw new Error(cameraMock.getError);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return Promise.resolve(cameraMock.getResponse);
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
get requestCameraPermissionsAsync() {
|
|
74
|
+
if (cameraMock.mode !== 'topLevel') {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
if (cameraMock.requestError !== undefined) {
|
|
80
|
+
throw new Error(cameraMock.requestError);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Promise.resolve(cameraMock.requestResponse);
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
get Camera() {
|
|
87
|
+
if (cameraMock.mode !== 'cameraNamespace') {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
getCameraPermissionsAsync: () => Promise.resolve(cameraMock.getResponse),
|
|
93
|
+
requestCameraPermissionsAsync: () => Promise.resolve(cameraMock.requestResponse),
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
void mock.module('expo-audio', () => ({
|
|
99
|
+
get getRecordingPermissionsAsync() {
|
|
100
|
+
if (audioMock.mode !== 'topLevel') {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
if (audioMock.getError !== undefined) {
|
|
106
|
+
throw new Error(audioMock.getError);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return Promise.resolve(audioMock.getResponse);
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
get requestRecordingPermissionsAsync() {
|
|
113
|
+
if (audioMock.mode !== 'topLevel') {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
if (audioMock.requestError !== undefined) {
|
|
119
|
+
throw new Error(audioMock.requestError);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return Promise.resolve(audioMock.requestResponse);
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
get AudioModule() {
|
|
126
|
+
if (audioMock.mode !== 'audioModule') {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
getRecordingPermissionsAsync: () => Promise.resolve(audioMock.getResponse),
|
|
132
|
+
requestRecordingPermissionsAsync: () => Promise.resolve(audioMock.requestResponse),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
void mock.module('expo-media-library', () => ({
|
|
138
|
+
getPermissionsAsync: (writeOnly?: boolean) => {
|
|
139
|
+
mediaLibraryMock.getCalls.push(writeOnly === true);
|
|
140
|
+
|
|
141
|
+
if (mediaLibraryMock.getError !== undefined) {
|
|
142
|
+
throw new Error(mediaLibraryMock.getError);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Promise.resolve(mediaLibraryMock.getResponse);
|
|
146
|
+
},
|
|
147
|
+
requestPermissionsAsync: (writeOnly?: boolean) => {
|
|
148
|
+
mediaLibraryMock.requestCalls.push(writeOnly === true);
|
|
149
|
+
|
|
150
|
+
if (mediaLibraryMock.requestError !== undefined) {
|
|
151
|
+
throw new Error(mediaLibraryMock.requestError);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Promise.resolve(mediaLibraryMock.requestResponse);
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
void mock.module('expo-location', () => ({
|
|
159
|
+
getForegroundPermissionsAsync: () => {
|
|
160
|
+
locationMock.getCalls.push('foreground');
|
|
161
|
+
|
|
162
|
+
return Promise.resolve(locationMock.foregroundGetResponse);
|
|
163
|
+
},
|
|
164
|
+
requestForegroundPermissionsAsync: () => {
|
|
165
|
+
locationMock.requestCalls.push('foreground');
|
|
166
|
+
|
|
167
|
+
return Promise.resolve(locationMock.foregroundRequestResponse);
|
|
168
|
+
},
|
|
169
|
+
getBackgroundPermissionsAsync: () => {
|
|
170
|
+
locationMock.getCalls.push('background');
|
|
171
|
+
|
|
172
|
+
return Promise.resolve(locationMock.backgroundGetResponse);
|
|
173
|
+
},
|
|
174
|
+
requestBackgroundPermissionsAsync: () => {
|
|
175
|
+
locationMock.requestCalls.push('background');
|
|
176
|
+
|
|
177
|
+
return Promise.resolve(locationMock.backgroundRequestResponse);
|
|
178
|
+
},
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
void mock.module('expo-notifications', () => ({
|
|
182
|
+
getPermissionsAsync: () => {
|
|
183
|
+
if (notificationsMock.getError !== undefined) {
|
|
184
|
+
throw new Error(notificationsMock.getError);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return Promise.resolve(notificationsMock.getResponse);
|
|
188
|
+
},
|
|
189
|
+
requestPermissionsAsync: () => {
|
|
190
|
+
if (notificationsMock.requestError !== undefined) {
|
|
191
|
+
throw new Error(notificationsMock.requestError);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return Promise.resolve(notificationsMock.requestResponse);
|
|
195
|
+
},
|
|
196
|
+
}));
|
|
197
|
+
|
|
198
|
+
describe('createPermissionClient', () => {
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
resetExpoMocks();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('covers getStatus and request for every known permission', async () => {
|
|
204
|
+
const client = createPermissionClient();
|
|
205
|
+
|
|
206
|
+
for (const permission of PERMISSIONS) {
|
|
207
|
+
const statusState = await client.getStatus(permission);
|
|
208
|
+
const requestState = await client.request(permission);
|
|
209
|
+
|
|
210
|
+
expect(statusState.permission).toBe(permission);
|
|
211
|
+
expect(statusState.status).toBe('granted');
|
|
212
|
+
expect(statusState.granted).toBe(true);
|
|
213
|
+
|
|
214
|
+
expect(requestState.permission).toBe(permission);
|
|
215
|
+
expect(requestState.status).toBe('granted');
|
|
216
|
+
expect(requestState.granted).toBe(true);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
expect(mediaLibraryMock.getCalls).toEqual([false, true]);
|
|
220
|
+
expect(mediaLibraryMock.requestCalls).toEqual([false, true]);
|
|
221
|
+
expect(locationMock.getCalls).toEqual(['foreground', 'background']);
|
|
222
|
+
expect(locationMock.requestCalls).toEqual(['foreground', 'background']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('treats clipboard as not requiring a native Expo permission module', async () => {
|
|
226
|
+
const client = createPermissionClient();
|
|
227
|
+
|
|
228
|
+
const statusState = await client.getStatus(Permission.Clipboard);
|
|
229
|
+
const requestState = await client.request(Permission.Clipboard);
|
|
230
|
+
|
|
231
|
+
expect(statusState).toMatchObject({
|
|
232
|
+
permission: Permission.Clipboard,
|
|
233
|
+
status: 'granted',
|
|
234
|
+
granted: true,
|
|
235
|
+
});
|
|
236
|
+
expect(requestState).toMatchObject({
|
|
237
|
+
permission: Permission.Clipboard,
|
|
238
|
+
status: 'granted',
|
|
239
|
+
granted: true,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('falls back to the Camera namespace when camera permission functions are nested', async () => {
|
|
244
|
+
cameraMock.mode = 'cameraNamespace';
|
|
245
|
+
cameraMock.getResponse = permissionResponse('denied', false);
|
|
246
|
+
cameraMock.requestResponse = permissionResponse('granted');
|
|
247
|
+
|
|
248
|
+
const client = createPermissionClient();
|
|
249
|
+
|
|
250
|
+
expect(await client.getStatus(Permission.Camera)).toMatchObject({
|
|
251
|
+
permission: Permission.Camera,
|
|
252
|
+
status: 'denied',
|
|
253
|
+
granted: false,
|
|
254
|
+
canAskAgain: false,
|
|
255
|
+
});
|
|
256
|
+
expect(await client.request(Permission.Camera)).toMatchObject({
|
|
257
|
+
permission: Permission.Camera,
|
|
258
|
+
status: 'granted',
|
|
259
|
+
granted: true,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('falls back to AudioModule recording permission functions when top-level exports are absent', async () => {
|
|
264
|
+
audioMock.mode = 'audioModule';
|
|
265
|
+
audioMock.getResponse = permissionResponse('denied', false);
|
|
266
|
+
audioMock.requestResponse = permissionResponse('granted');
|
|
267
|
+
|
|
268
|
+
const client = createPermissionClient();
|
|
269
|
+
|
|
270
|
+
expect(await client.getStatus(Permission.Microphone)).toMatchObject({
|
|
271
|
+
permission: Permission.Microphone,
|
|
272
|
+
status: 'denied',
|
|
273
|
+
granted: false,
|
|
274
|
+
canAskAgain: false,
|
|
275
|
+
});
|
|
276
|
+
expect(await client.request(Permission.Microphone)).toMatchObject({
|
|
277
|
+
permission: Permission.Microphone,
|
|
278
|
+
status: 'granted',
|
|
279
|
+
granted: true,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('normalizes unknown adapter statuses to unknown', async () => {
|
|
284
|
+
notificationsMock.getResponse = permissionResponse('mystery-status', false);
|
|
285
|
+
notificationsMock.requestResponse = permissionResponse('denied', false);
|
|
286
|
+
|
|
287
|
+
const client = createPermissionClient();
|
|
288
|
+
const state = await client.getStatus(Permission.Notifications);
|
|
289
|
+
|
|
290
|
+
expect(state).toMatchObject({
|
|
291
|
+
permission: Permission.Notifications,
|
|
292
|
+
status: 'unknown',
|
|
293
|
+
granted: false,
|
|
294
|
+
canAskAgain: false,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('preserves denied request responses', async () => {
|
|
299
|
+
notificationsMock.requestResponse = permissionResponse('denied', false);
|
|
300
|
+
|
|
301
|
+
const client = createPermissionClient();
|
|
302
|
+
const state = await client.request(Permission.Notifications);
|
|
303
|
+
|
|
304
|
+
expect(state).toMatchObject({
|
|
305
|
+
permission: Permission.Notifications,
|
|
306
|
+
status: 'denied',
|
|
307
|
+
granted: false,
|
|
308
|
+
canAskAgain: false,
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('returns unavailable when a camera permission call fails at runtime', async () => {
|
|
313
|
+
cameraMock.getError = 'camera bridge unavailable';
|
|
314
|
+
|
|
315
|
+
const client = createPermissionClient();
|
|
316
|
+
const state = await client.getStatus(Permission.Camera);
|
|
317
|
+
|
|
318
|
+
expect(state).toMatchObject({
|
|
319
|
+
permission: Permission.Camera,
|
|
320
|
+
status: 'unavailable',
|
|
321
|
+
granted: false,
|
|
322
|
+
reason: 'camera bridge unavailable',
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('returns unavailable when a request call throws at runtime', async () => {
|
|
327
|
+
audioMock.requestError = 'microphone bridge unavailable';
|
|
328
|
+
|
|
329
|
+
const client = createPermissionClient();
|
|
330
|
+
const state = await client.request(Permission.Microphone);
|
|
331
|
+
|
|
332
|
+
expect(state).toMatchObject({
|
|
333
|
+
permission: Permission.Microphone,
|
|
334
|
+
status: 'unavailable',
|
|
335
|
+
granted: false,
|
|
336
|
+
reason: 'microphone bridge unavailable',
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
function resetExpoMocks(): void {
|
|
342
|
+
cameraMock.mode = 'topLevel';
|
|
343
|
+
cameraMock.getResponse = permissionResponse('granted');
|
|
344
|
+
cameraMock.requestResponse = permissionResponse('granted');
|
|
345
|
+
cameraMock.getError = undefined;
|
|
346
|
+
cameraMock.requestError = undefined;
|
|
347
|
+
|
|
348
|
+
audioMock.mode = 'topLevel';
|
|
349
|
+
audioMock.getResponse = permissionResponse('granted');
|
|
350
|
+
audioMock.requestResponse = permissionResponse('granted');
|
|
351
|
+
audioMock.getError = undefined;
|
|
352
|
+
audioMock.requestError = undefined;
|
|
353
|
+
|
|
354
|
+
mediaLibraryMock.getResponse = permissionResponse('granted');
|
|
355
|
+
mediaLibraryMock.requestResponse = permissionResponse('granted');
|
|
356
|
+
mediaLibraryMock.getError = undefined;
|
|
357
|
+
mediaLibraryMock.requestError = undefined;
|
|
358
|
+
mediaLibraryMock.getCalls.length = 0;
|
|
359
|
+
mediaLibraryMock.requestCalls.length = 0;
|
|
360
|
+
|
|
361
|
+
locationMock.foregroundGetResponse = permissionResponse('granted');
|
|
362
|
+
locationMock.foregroundRequestResponse = permissionResponse('granted');
|
|
363
|
+
locationMock.backgroundGetResponse = permissionResponse('granted');
|
|
364
|
+
locationMock.backgroundRequestResponse = permissionResponse('granted');
|
|
365
|
+
locationMock.getCalls.length = 0;
|
|
366
|
+
locationMock.requestCalls.length = 0;
|
|
367
|
+
|
|
368
|
+
notificationsMock.getResponse = permissionResponse('granted');
|
|
369
|
+
notificationsMock.requestResponse = permissionResponse('granted');
|
|
370
|
+
notificationsMock.getError = undefined;
|
|
371
|
+
notificationsMock.requestError = undefined;
|
|
372
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { PermissionClient } from '../client/types';
|
|
2
|
+
import { Permission } from '../registry/permissions';
|
|
3
|
+
import { createPermissionState, type PermissionState } from '../state/permissionState';
|
|
4
|
+
import { cameraAdapter } from './adapters/camera';
|
|
5
|
+
import { locationAdapter } from './adapters/location';
|
|
6
|
+
import { mediaLibraryAdapter } from './adapters/mediaLibrary';
|
|
7
|
+
import { microphoneAdapter } from './adapters/microphone';
|
|
8
|
+
import { notificationsAdapter } from './adapters/notifications';
|
|
9
|
+
import { EXPO_PERMISSION_SUPPORT } from './manifest';
|
|
10
|
+
|
|
11
|
+
export interface ExpoPermissionAdapter {
|
|
12
|
+
getStatus(permission: Permission): Promise<PermissionState>;
|
|
13
|
+
request(permission: Permission): Promise<PermissionState>;
|
|
14
|
+
}
|
|
15
|
+
|
|
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
|
+
};
|
|
25
|
+
|
|
26
|
+
export function createPermissionClient(): PermissionClient {
|
|
27
|
+
return {
|
|
28
|
+
async getStatus(permission: Permission): Promise<PermissionState> {
|
|
29
|
+
const metadata = EXPO_PERMISSION_SUPPORT[permission];
|
|
30
|
+
if (metadata.support === 'unsupported' || metadata.support === 'notImplemented') {
|
|
31
|
+
return createUnavailableSupportState(permission);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (metadata.support === 'notRequired') {
|
|
35
|
+
return createNotRequiredState(permission);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const adapter = adapters[permission];
|
|
39
|
+
if (!adapter) {
|
|
40
|
+
throw new Error(`No adapter registered for permission: ${permission}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return adapter.getStatus(permission);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async request(permission: Permission): Promise<PermissionState> {
|
|
47
|
+
const metadata = EXPO_PERMISSION_SUPPORT[permission];
|
|
48
|
+
if (metadata.support === 'unsupported' || metadata.support === 'notImplemented') {
|
|
49
|
+
return createUnavailableSupportState(permission);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (metadata.support === 'notRequired') {
|
|
53
|
+
return createNotRequiredState(permission);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const adapter = adapters[permission];
|
|
57
|
+
if (!adapter) {
|
|
58
|
+
throw new Error(`No adapter registered for permission: ${permission}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return adapter.request(permission);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createUnavailableSupportState(permission: Permission): PermissionState {
|
|
67
|
+
return createPermissionState({
|
|
68
|
+
permission,
|
|
69
|
+
status: 'unavailable',
|
|
70
|
+
reason: `Permission ${permission} is not implemented or supported on Expo.`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createNotRequiredState(permission: Permission): PermissionState {
|
|
75
|
+
return createPermissionState({
|
|
76
|
+
permission,
|
|
77
|
+
status: 'granted',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { Permission, PERMISSIONS } from '../registry/permissions';
|
|
6
|
+
import { EXPO_PERMISSION_SUPPORT } from './manifest';
|
|
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');
|
|
15
|
+
|
|
16
|
+
expect(source).not.toContain("from 'expo-");
|
|
17
|
+
expect(source).not.toContain('await import(');
|
|
18
|
+
});
|
|
19
|
+
|
|
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
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(EXPO_PERMISSION_SUPPORT[Permission.Microphone]).toEqual({
|
|
28
|
+
support: 'supported',
|
|
29
|
+
requiredPackages: ['expo-audio'],
|
|
30
|
+
configHints: ['microphonePermission', 'recordAudioAndroid'],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(EXPO_PERMISSION_SUPPORT[Permission.LocationForeground]).toEqual({
|
|
34
|
+
support: 'supported',
|
|
35
|
+
requiredPackages: ['expo-location'],
|
|
36
|
+
configHints: ['locationWhenInUsePermission'],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(EXPO_PERMISSION_SUPPORT[Permission.LocationBackground]).toEqual({
|
|
40
|
+
support: 'supported',
|
|
41
|
+
requiredPackages: ['expo-location'],
|
|
42
|
+
configHints: ['locationAlwaysAndWhenInUsePermission'],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
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
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
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
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(EXPO_PERMISSION_SUPPORT[Permission.MediaLibraryWrite]).toEqual(
|
|
62
|
+
EXPO_PERMISSION_SUPPORT[Permission.MediaLibrary],
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|