@ankhorage/permissions 0.1.1 → 0.2.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/CHANGELOG.md +6 -0
- package/README.md +97 -21
- 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 +13 -1
- 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/react/index.tsx +0 -14
- package/src/testing/index.ts +0 -8
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Permission } from '../registry/permissions';
|
|
2
|
+
|
|
3
|
+
export type PermissionSupport =
|
|
4
|
+
| 'supported'
|
|
5
|
+
| 'notRequired'
|
|
6
|
+
| 'limited'
|
|
7
|
+
| 'notImplemented'
|
|
8
|
+
| 'unsupported';
|
|
9
|
+
|
|
10
|
+
export interface ExpoPermissionMetadata {
|
|
11
|
+
readonly support: PermissionSupport;
|
|
12
|
+
readonly requiredPackages: readonly string[];
|
|
13
|
+
readonly configHints: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const EXPO_PERMISSION_SUPPORT: Readonly<Record<Permission, ExpoPermissionMetadata>> = {
|
|
17
|
+
[Permission.Camera]: {
|
|
18
|
+
support: 'supported',
|
|
19
|
+
requiredPackages: ['expo-camera'],
|
|
20
|
+
configHints: ['cameraPermission'],
|
|
21
|
+
},
|
|
22
|
+
[Permission.Microphone]: {
|
|
23
|
+
support: 'supported',
|
|
24
|
+
requiredPackages: ['expo-audio'],
|
|
25
|
+
configHints: ['microphonePermission', 'recordAudioAndroid'],
|
|
26
|
+
},
|
|
27
|
+
[Permission.MediaLibrary]: {
|
|
28
|
+
support: 'supported',
|
|
29
|
+
requiredPackages: ['expo-media-library'],
|
|
30
|
+
configHints: ['mediaLibraryPermission'],
|
|
31
|
+
},
|
|
32
|
+
[Permission.MediaLibraryWrite]: {
|
|
33
|
+
support: 'supported',
|
|
34
|
+
requiredPackages: ['expo-media-library'],
|
|
35
|
+
configHints: ['mediaLibraryPermission'],
|
|
36
|
+
},
|
|
37
|
+
[Permission.LocationForeground]: {
|
|
38
|
+
support: 'supported',
|
|
39
|
+
requiredPackages: ['expo-location'],
|
|
40
|
+
configHints: ['locationWhenInUsePermission'],
|
|
41
|
+
},
|
|
42
|
+
[Permission.LocationBackground]: {
|
|
43
|
+
support: 'supported',
|
|
44
|
+
requiredPackages: ['expo-location'],
|
|
45
|
+
configHints: ['locationAlwaysAndWhenInUsePermission'],
|
|
46
|
+
},
|
|
47
|
+
[Permission.Notifications]: {
|
|
48
|
+
support: 'supported',
|
|
49
|
+
requiredPackages: ['expo-notifications'],
|
|
50
|
+
configHints: ['notificationsPermission'],
|
|
51
|
+
},
|
|
52
|
+
[Permission.Clipboard]: {
|
|
53
|
+
support: 'notRequired',
|
|
54
|
+
requiredPackages: [],
|
|
55
|
+
configHints: [],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
interface ExpoPermissionResult {
|
|
2
|
+
status: string;
|
|
3
|
+
canAskAgain?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module 'expo-camera' {
|
|
7
|
+
export function getCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
8
|
+
export function requestCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
9
|
+
export const CameraNativeModule: {
|
|
10
|
+
getCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
11
|
+
requestCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
12
|
+
};
|
|
13
|
+
export const Camera: {
|
|
14
|
+
getCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
15
|
+
requestCameraPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare module 'expo-location' {
|
|
20
|
+
export function getBackgroundPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
21
|
+
export function getForegroundPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
22
|
+
export function requestBackgroundPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
23
|
+
export function requestForegroundPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module 'expo-media-library' {
|
|
27
|
+
export function getPermissionsAsync(
|
|
28
|
+
writeOnly?: boolean,
|
|
29
|
+
granularPermissions?: readonly string[],
|
|
30
|
+
): Promise<ExpoPermissionResult>;
|
|
31
|
+
export function requestPermissionsAsync(
|
|
32
|
+
writeOnly?: boolean,
|
|
33
|
+
granularPermissions?: readonly string[],
|
|
34
|
+
): Promise<ExpoPermissionResult>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare module 'expo-audio' {
|
|
38
|
+
export function getRecordingPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
39
|
+
export function requestRecordingPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
40
|
+
export const AudioModule: {
|
|
41
|
+
getRecordingPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
42
|
+
requestRecordingPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
43
|
+
};
|
|
44
|
+
export const Audio: {
|
|
45
|
+
getRecordingPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
46
|
+
requestRecordingPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
declare module 'expo-notifications' {
|
|
51
|
+
export function getPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
52
|
+
export function requestPermissionsAsync(): Promise<ExpoPermissionResult>;
|
|
53
|
+
}
|