diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index edb01b1c4..33434d121 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -2116,12 +2116,16 @@ for (const snippet of [ "message: 'file sharing failed'", 'rejects image import before picker launch when library permission is denied', 'rejects image import before picker launch when library permission request fails', + 'maps native image library launch failures to stable import errors', 'rejects image capture before camera launch when camera permission request fails', + 'maps native camera launch failures to stable capture errors', "message: 'photo library permission denied'", "message: 'photo library permission unavailable'", + "message: 'photo library unavailable'", 'expect(imageLibraryMock).not.toHaveBeenCalled()', "message: 'camera permission denied'", "message: 'camera permission unavailable'", + "message: 'camera unavailable'", 'expect(cameraMock).not.toHaveBeenCalled()', "mediaTypes: ['images']", "options: { encoding: 'base64' }", diff --git a/apps/mobile-shell/src/host-bridge/files.test.ts b/apps/mobile-shell/src/host-bridge/files.test.ts index ece35fb0f..3fa8245c1 100644 --- a/apps/mobile-shell/src/host-bridge/files.test.ts +++ b/apps/mobile-shell/src/host-bridge/files.test.ts @@ -354,6 +354,21 @@ describe('mobile HostBridge file actions', () => { expect(imageLibraryMock).not.toHaveBeenCalled(); }); + test('maps native image library launch failures to stable import errors', async () => { + libraryPermissionMock.mockResolvedValueOnce({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + imageLibraryMock.mockRejectedValueOnce(new Error('native image picker failed')); + + await expect(importImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'photo library unavailable', + }); + }); + test('rejects image capture before camera launch when camera permission request fails', async () => { cameraPermissionMock.mockRejectedValueOnce(new Error('native permission failed')); @@ -364,6 +379,21 @@ describe('mobile HostBridge file actions', () => { expect(cameraMock).not.toHaveBeenCalled(); }); + test('maps native camera launch failures to stable capture errors', async () => { + cameraPermissionMock.mockResolvedValueOnce({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + cameraMock.mockRejectedValueOnce(new Error('native camera failed')); + + await expect(captureImageFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'camera unavailable', + }); + }); + test('imports audio and exports binary files through controlled payloads', async () => { fileBase64Data.set('file:///picked/voice.mp3', MP3_BASE64); fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10')); diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index 4697134a1..791baf4d8 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -331,14 +331,22 @@ export async function importImageFile(): Promise { } satisfies HostBridgeError; } - const result = await ImagePicker.launchImageLibraryAsync({ - allowsEditing: false, - allowsMultipleSelection: false, - base64: true, - exif: false, - mediaTypes: ['images'], - quality: 1, - }); + let result: ImagePicker.ImagePickerResult; + try { + result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: false, + allowsMultipleSelection: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + } catch { + throw { + code: 'host_error', + message: 'photo library unavailable', + } satisfies HostBridgeError; + } const payload = imagePickerResultToImportPayload(result, 'selected'); if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) { @@ -370,13 +378,21 @@ export async function captureImageFile(): Promise { } satisfies HostBridgeError; } - const result = await ImagePicker.launchCameraAsync({ - allowsEditing: false, - base64: true, - exif: false, - mediaTypes: ['images'], - quality: 1, - }); + let result: ImagePicker.ImagePickerResult; + try { + result = await ImagePicker.launchCameraAsync({ + allowsEditing: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + } catch { + throw { + code: 'host_error', + message: 'camera unavailable', + } satisfies HostBridgeError; + } const payload = imagePickerResultToImportPayload(result, 'captured'); if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) {