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)