From e09137a449d595408e0686bf251cb84b28ecd5ba Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 14:56:50 -0700 Subject: [PATCH] RIC-T40 Permissions fix --- app.config.ts | 10 +++++++ .../android-media-permissions.test.ts | 29 +++++++++++++++++++ .../calls/__tests__/call-image-picker.test.ts | 27 +++++++++++++++++ src/components/calls/call-image-picker.ts | 9 ++++++ src/components/calls/call-images-modal.tsx | 12 ++------ 5 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 plugins/__tests__/android-media-permissions.test.ts create mode 100644 src/components/calls/__tests__/call-image-picker.test.ts create mode 100644 src/components/calls/call-image-picker.ts diff --git a/app.config.ts b/app.config.ts index d3edb08..2c13432 100644 --- a/app.config.ts +++ b/app.config.ts @@ -98,6 +98,16 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'android.permission.READ_PHONE_NUMBERS', 'android.permission.MANAGE_OWN_CALLS', ], + // Media is selected through Android's system Photo Picker. Block broad media + // and legacy storage permissions even when a transitive native dependency + // contributes them during manifest merging. + blockedPermissions: [ + 'android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_MEDIA_VIDEO', + 'android.permission.READ_MEDIA_VISUAL_USER_SELECTED', + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.WRITE_EXTERNAL_STORAGE', + ], }, web: { favicon: './assets/favicon.png', diff --git a/plugins/__tests__/android-media-permissions.test.ts b/plugins/__tests__/android-media-permissions.test.ts new file mode 100644 index 0000000..c395afb --- /dev/null +++ b/plugins/__tests__/android-media-permissions.test.ts @@ -0,0 +1,29 @@ +import type { ConfigContext } from '@expo/config'; + +import createExpoConfig from '../../app.config'; + +jest.mock('zod', () => jest.requireActual('zod')); + +const blockedMediaPermissions = [ + 'android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_MEDIA_VIDEO', + 'android.permission.READ_MEDIA_VISUAL_USER_SELECTED', + 'android.permission.READ_EXTERNAL_STORAGE', + 'android.permission.WRITE_EXTERNAL_STORAGE', +]; + +describe('Android media permissions', () => { + it('blocks broad media access so system pickers remain the only gallery access path', () => { + const config = createExpoConfig({ + config: { + name: 'Resgrid IC', + slug: 'resgrid-ic', + }, + } as ConfigContext); + + expect(config.android?.blockedPermissions).toEqual(expect.arrayContaining(blockedMediaPermissions)); + blockedMediaPermissions.forEach((permission) => { + expect(config.android?.permissions).not.toContain(permission); + }); + }); +}); diff --git a/src/components/calls/__tests__/call-image-picker.test.ts b/src/components/calls/__tests__/call-image-picker.test.ts new file mode 100644 index 0000000..b227574 --- /dev/null +++ b/src/components/calls/__tests__/call-image-picker.test.ts @@ -0,0 +1,27 @@ +import * as ImagePicker from 'expo-image-picker'; + +import { launchCallImagePicker } from '@/components/calls/call-image-picker'; + +jest.mock('expo-image-picker', () => ({ + launchImageLibraryAsync: jest.fn(), +})); + +const mockLaunchImageLibraryAsync = ImagePicker.launchImageLibraryAsync as jest.MockedFunction; + +describe('launchCallImagePicker', () => { + it('uses the non-legacy system image picker without requesting broad media access', async () => { + const pickerResult: ImagePicker.ImagePickerResult = { + assets: null, + canceled: true, + }; + mockLaunchImageLibraryAsync.mockResolvedValue(pickerResult); + + await expect(launchCallImagePicker()).resolves.toBe(pickerResult); + expect(mockLaunchImageLibraryAsync).toHaveBeenCalledWith({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.8, + legacy: false, + }); + }); +}); diff --git a/src/components/calls/call-image-picker.ts b/src/components/calls/call-image-picker.ts new file mode 100644 index 0000000..3905375 --- /dev/null +++ b/src/components/calls/call-image-picker.ts @@ -0,0 +1,9 @@ +import * as ImagePicker from 'expo-image-picker'; + +export const launchCallImagePicker = (): Promise => + ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.8, + legacy: false, + }); diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index 7484dd8..2b845ea 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; import { KeyboardStickyView } from 'react-native-keyboard-controller'; +import { launchCallImagePicker } from '@/components/calls/call-image-picker'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { useAnalytics } from '@/hooks/use-analytics'; @@ -111,16 +112,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call const handleImageSelect = async () => { try { - const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (permissionResult.status !== 'granted') { - alert(t('common.permission_denied')); - return; - } - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ['images'], - allowsEditing: true, - quality: 0.8, - }); + const result = await launchCallImagePicker(); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; const filename = asset.fileName || `image_${Date.now()}.png`;