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

@@ -3,8 +3,8 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
@@ -52,4 +52,4 @@
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/>
</receiver> </receiver>
</application> </application>
</manifest> </manifest>
+4
View File
@@ -211,6 +211,10 @@
"buildToolsVersion": "36.0.0", "buildToolsVersion": "36.0.0",
"compileSdkVersion": 36, "compileSdkVersion": 36,
"targetSdkVersion": 36, "targetSdkVersion": 36,
"blockedPermissions": [
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO"
],
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }", "extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
"useLegacyPackaging": false, "useLegacyPackaging": false,
"manifestPlaceholders": { "manifestPlaceholders": {
@@ -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 { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
@@ -41,12 +41,13 @@ describe('useMediaSaver', () => {
(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined); (FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined);
}); });
it('initializes with saving as false', () => { it('initializes with saving as false', () => {
const { result } = renderHook(() => useMediaSaver()); const { result } = renderHook(() => useMediaSaver());
expect(result.current.saving).toBe(false); expect(result.current.saving).toBe(false);
expect(typeof result.current.saveMedia).toBe('function'); expect(typeof result.current.saveMedia).toBe('function');
}); expect(MediaLibrary.usePermissions).toHaveBeenCalledWith({ writeOnly: true });
});
it('saves local image successfully', async () => { it('saves local image successfully', async () => {
const { result } = renderHook(() => useMediaSaver()); const { result } = renderHook(() => useMediaSaver());
@@ -179,10 +180,8 @@ describe('useMediaSaver', () => {
expect(result.current.saving).toBe(false); expect(result.current.saving).toBe(false);
}); });
it('sets saving to true during operation', async () => { it('sets saving to true during operation', async () => {
let savingDuringOperation = false; (MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
// This would capture the saving state during the async operation // This would capture the saving state during the async operation
// but since we can't access result.current here synchronously, // but since we can't access result.current here synchronously,
// we'll test this differently // we'll test this differently
@@ -0,0 +1,46 @@
import { act, renderHook } from '@testing-library/react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system/legacy';
import { useCanvasCapture } from './useCanvasCapture';
jest.mock('expo-media-library', () => ({
requestPermissionsAsync: jest.fn(),
saveToLibraryAsync: jest.fn(),
}));
jest.mock('expo-file-system/legacy', () => ({
cacheDirectory: 'file:///cache/',
writeAsStringAsync: jest.fn(),
EncodingType: { Base64: 'base64' },
}));
describe('useCanvasCapture', () => {
beforeEach(() => {
jest.clearAllMocks();
(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined);
});
it('requests add-only permission before saving the captured image', async () => {
const { result } = renderHook(() => useCanvasCapture());
result.current.makeSnapshot.current = () => ({
encodeToBase64: () => 'encoded-image',
}) as never;
let saved = false;
await act(async () => {
saved = await result.current.saveToGallery();
});
expect(saved).toBe(true);
expect(MediaLibrary.requestPermissionsAsync).toHaveBeenCalledWith(true);
expect(MediaLibrary.saveToLibraryAsync).toHaveBeenCalledWith(
expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/)
);
expect(FileSystem.writeAsStringAsync).toHaveBeenCalledWith(
expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/),
'encoded-image',
{ encoding: 'base64' }
);
});
});
@@ -39,7 +39,9 @@ export function useCanvasCapture(): UseCanvasCaptureReturn {
const fileUri = await captureToFile(); const fileUri = await captureToFile();
if (!fileUri) return false; if (!fileUri) return false;
const { status } = await MediaLibrary.requestPermissionsAsync(); // The capture is created by Sauci, so request add-only access rather than
// broad photo-library read access.
const { status } = await MediaLibrary.requestPermissionsAsync(true);
if (status !== 'granted') return false; if (status !== 'granted') return false;
await MediaLibrary.saveToLibraryAsync(fileUri); await MediaLibrary.saveToLibraryAsync(fileUri);
+11 -7
View File
@@ -100,13 +100,17 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
quality: 0.8, quality: 0.8,
exif: false, exif: false,
}); });
} else { } else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); // Android's system photo picker grants the selected file directly, so
if (status !== 'granted') { // selecting an avatar does not need broad media-library access.
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.'); if (Platform.OS !== 'android') {
return; const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
} if (status !== 'granted') {
result = await ImagePicker.launchImageLibraryAsync({ Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'], mediaTypes: ['images'],
allowsEditing: true, allowsEditing: true,
aspect: [1, 1], aspect: [1, 1],
+3 -1
View File
@@ -5,7 +5,9 @@ import * as FileSystem from 'expo-file-system/legacy';
export function useMediaSaver() { export function useMediaSaver() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions(); // Saving app-created/downloaded media only needs add-only access. Passing
// writeOnly prevents this path from requesting broad library read access.
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({ writeOnly: true });
const saveMedia = async (uri: string, type: 'image' | 'video') => { const saveMedia = async (uri: string, type: 'image' | 'video') => {
try { try {
+9
View File
@@ -85,6 +85,15 @@ may change: `app.json`, the platform native version file, and the iOS widget
`Info.plist` for an iOS build. Any widget implementation, resource, or Xcode `Info.plist` for an iOS build. Any widget implementation, resource, or Xcode
project rewrite means the source was regenerated and the artifact must be rebuilt project rewrite means the source was regenerated and the artifact must be rebuilt
from the clean tracked sources. from the clean tracked sources.
### Android media permission policy
Sauci uses the Android system photo picker for user-selected images and videos.
Android release manifests must not contain `READ_MEDIA_IMAGES` or
`READ_MEDIA_VIDEO`. Gallery save flows request add-only access and must not add
broad read access. After any Expo, media-library, image-picker, or native Android
change, run `:app:processReleaseMainManifest` and inspect the merged release
manifest before building an artifact for Google Play.
### Building an APK (for Testing) ### Building an APK (for Testing)