mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
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>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user