fix(android): use system photo picker

Block broad image and video read permissions in Expo config and the checked-in Android manifest. Keep picker access scoped to user-selected media and gallery saves write-only. Add regression coverage and document the release-manifest invariant.
This commit is contained in:
jjbrunton committed 2026-08-30 17:15:57 +01:00
1 parent 5e581efe51
commit 8658fc38fe
9 files changed
+156 -23

No files matched your search

@@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react-native';
import { ActionSheetIOS, Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker';
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn(),
}));
jest.mock('expo-image-picker', () => ({
requestCameraPermissionsAsync: jest.fn(),
requestMediaLibraryPermissionsAsync: jest.fn(),
launchCameraAsync: jest.fn(),
launchImageLibraryAsync: jest.fn(),
}));
describe('useAvatarPicker', () => {
const originalPlatform = Platform.OS;
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
});
afterEach(() => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: originalPlatform });
});
it('uses Android system picker without requesting broad media access', async () => {
const { result } = renderHook(() => useAvatarPicker());
act(() => result.current.showPicker());
const chooseFromLibrary = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Choose from Library'
);
await act(async () => chooseFromLibrary.onPress());
expect(ImagePicker.requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled();
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
exif: false,
});
});
it('keeps the iOS library permission request before opening the picker', async () => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' });
(ImagePicker.requestMediaLibraryPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
const { result } = renderHook(() => useAvatarPicker());
act(() => result.current.showPicker());
const onSelect = (ActionSheetIOS.showActionSheetWithOptions as jest.Mock).mock.calls[0][1];
await act(async () => onSelect(2));
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { renderHook, act } from '@testing-library/react-native';
import { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system/legacy';
@@ -41,12 +41,13 @@ describe('useMediaSaver', () => {
(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined);
});
it('initializes with saving as false', () => {
const { result } = renderHook(() => useMediaSaver());
expect(result.current.saving).toBe(false);
expect(typeof result.current.saveMedia).toBe('function');
});
it('initializes with saving as false', () => {
const { result } = renderHook(() => useMediaSaver());
expect(result.current.saving).toBe(false);
expect(typeof result.current.saveMedia).toBe('function');
expect(MediaLibrary.usePermissions).toHaveBeenCalledWith({ writeOnly: true });
});
it('saves local image successfully', async () => {
const { result } = renderHook(() => useMediaSaver());
@@ -179,10 +180,8 @@ describe('useMediaSaver', () => {
expect(result.current.saving).toBe(false);
});
it('sets saving to true during operation', async () => {
let savingDuringOperation = false;
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
it('sets saving to true during operation', async () => {
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
// This would capture the saving state during the async operation
// but since we can't access result.current here synchronously,
// we'll test this differently