diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 36c65e1..aafee4f 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -3,8 +3,8 @@ - - + + @@ -52,4 +52,4 @@ - \ No newline at end of file + diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 123ada7..e3b50b8 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -211,6 +211,10 @@ "buildToolsVersion": "36.0.0", "compileSdkVersion": 36, "targetSdkVersion": 36, + "blockedPermissions": [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO" + ], "extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }", "useLegacyPackaging": false, "manifestPlaceholders": { diff --git a/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts b/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts new file mode 100644 index 0000000..ad82e4e --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts b/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts index dcb01a3..35e54a7 100644 --- a/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts +++ b/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts @@ -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 diff --git a/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.test.ts b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.test.ts new file mode 100644 index 0000000..789a070 --- /dev/null +++ b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.test.ts @@ -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' } + ); + }); +}); diff --git a/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts index 19df963..891161c 100644 --- a/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts +++ b/apps/mobile/src/features/live-draw/hooks/useCanvasCapture.ts @@ -39,7 +39,9 @@ export function useCanvasCapture(): UseCanvasCaptureReturn { const fileUri = await captureToFile(); 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; await MediaLibrary.saveToLibraryAsync(fileUri); diff --git a/apps/mobile/src/hooks/useAvatarPicker.ts b/apps/mobile/src/hooks/useAvatarPicker.ts index 53c46ff..5df7b96 100644 --- a/apps/mobile/src/hooks/useAvatarPicker.ts +++ b/apps/mobile/src/hooks/useAvatarPicker.ts @@ -100,13 +100,17 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick quality: 0.8, exif: false, }); - } else { - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== 'granted') { - Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.'); - return; - } - result = await ImagePicker.launchImageLibraryAsync({ + } else { + // Android's system photo picker grants the selected file directly, so + // selecting an avatar does not need broad media-library access. + if (Platform.OS !== 'android') { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.'); + return; + } + } + result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, aspect: [1, 1], diff --git a/apps/mobile/src/hooks/useMediaSaver.ts b/apps/mobile/src/hooks/useMediaSaver.ts index 499038f..5f9b5aa 100644 --- a/apps/mobile/src/hooks/useMediaSaver.ts +++ b/apps/mobile/src/hooks/useMediaSaver.ts @@ -5,7 +5,9 @@ import * as FileSystem from 'expo-file-system/legacy'; export function useMediaSaver() { 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') => { try { diff --git a/docs/releasing.md b/docs/releasing.md index 4727911..abb9f84 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 project rewrite means the source was regenerated and the artifact must be rebuilt 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)