diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts index 7a883014f..cc586f9a8 100644 --- a/assets/lang/strings.ts +++ b/assets/lang/strings.ts @@ -176,6 +176,10 @@ const translations = { message: 'Internxt Photos is disabled. Your photos and videos are not being backed up.', enableCta: 'Enable gallery backup in settings.', }, + limitedAccess: { + message: 'Internxt only has access to selected photos. ', + selectMoreCta: 'Select more photos.', + }, groupHeader: { items: 'items', backingUp: 'Backing up', @@ -1164,6 +1168,10 @@ const translations = { message: 'Internxt Photos está desactivado. Tus fotos y vídeos no se están guardando.', enableCta: 'Activa el backup en ajustes.', }, + limitedAccess: { + message: 'Internxt solo tiene acceso a fotos seleccionadas. ', + selectMoreCta: 'Seleccionar más fotos.', + }, groupHeader: { items: 'elementos', backingUp: 'Guardando copia', diff --git a/src/screens/PhotosScreen/EnableBackupBottomSheet.tsx b/src/screens/PhotosScreen/EnableBackupBottomSheet.tsx index fb16ce6a3..007084bb0 100644 --- a/src/screens/PhotosScreen/EnableBackupBottomSheet.tsx +++ b/src/screens/PhotosScreen/EnableBackupBottomSheet.tsx @@ -46,12 +46,16 @@ const EnableBackupBottomSheet = ({ isOpen, onClose, onSuccess }: EnableBackupBot return; } setStatus('loading'); - const result = await dispatch(enableBackupThunk()).unwrap(); - if (result.isGranted) { - handleClose(); - onSuccess?.(); - } else { - setStatus('denied'); + try { + const result = await dispatch(enableBackupThunk()).unwrap(); + if (result.isGranted) { + handleClose(); + onSuccess?.(); + } else { + setStatus('denied'); + } + } catch { + setStatus('idle'); } }; diff --git a/src/screens/PhotosScreen/components/LimitedAccessBanner.tsx b/src/screens/PhotosScreen/components/LimitedAccessBanner.tsx new file mode 100644 index 000000000..c3aebd9b1 --- /dev/null +++ b/src/screens/PhotosScreen/components/LimitedAccessBanner.tsx @@ -0,0 +1,31 @@ +import { ImageIcon } from 'phosphor-react-native'; +import { TouchableOpacity, View } from 'react-native'; +import AppText from 'src/components/AppText'; +import useGetColor from 'src/hooks/useColor'; +import { useTailwind } from 'tailwind-rn'; +import strings from '../../../../assets/lang/strings'; + +interface LimitedAccessBannerProps { + onSelectMorePress: () => void; +} + +const LimitedAccessBanner = ({ onSelectMorePress }: LimitedAccessBannerProps): JSX.Element => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + + return ( + + + + + {strings.screens.photos.limitedAccess.message} + + {strings.screens.photos.limitedAccess.selectMoreCta} + + + + + ); +}; + +export default LimitedAccessBanner; diff --git a/src/screens/PhotosScreen/hooks/useSelectMorePhotos.ts b/src/screens/PhotosScreen/hooks/useSelectMorePhotos.ts new file mode 100644 index 000000000..b6a8f8b50 --- /dev/null +++ b/src/screens/PhotosScreen/hooks/useSelectMorePhotos.ts @@ -0,0 +1,58 @@ +import * as MediaLibrary from 'expo-media-library'; +import { useCallback, useRef } from 'react'; +import { Platform } from 'react-native'; +import { photosActions, runBackupCycleThunk } from 'src/store/slices/photos'; +import { photoPermissionService } from '../../../services/photos/photoPermissionService'; +import { useAppDispatch } from '../../../store/hooks'; + +const PICKER_TIMEOUT_MS = 3 * 60 * 1000; + +/** + * Returns a callback that opens the system photo-selection picker so the user + * can expand their limited photo access, then reloads the timeline and starts + * a backup cycle. + * + * @param reloadLocal - Reloads the local asset list after the selection changes. + * @returns Async callback to invoke on "Select more photos" press. + */ +const useSelectMorePhotos = (reloadLocal: () => Promise): (() => Promise) => { + const dispatch = useAppDispatch(); + const timeoutRef = useRef | null>(null); + const listenerRef = useRef | null>(null); + + return useCallback(async () => { + if (Platform.OS === 'android') { + await MediaLibrary.requestPermissionsAsync(); + const newStatus = await photoPermissionService.getStatus(); + dispatch(photosActions.setPermissionStatus(newStatus)); + await reloadLocal(); + dispatch(runBackupCycleThunk()); + return; + } + + const cleanup = () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + listenerRef.current?.remove(); + listenerRef.current = null; + }; + + cleanup(); + + listenerRef.current = MediaLibrary.addListener(async () => { + cleanup(); + const newStatus = await photoPermissionService.getStatus(); + dispatch(photosActions.setPermissionStatus(newStatus)); + await reloadLocal(); + dispatch(runBackupCycleThunk()); + }); + + timeoutRef.current = setTimeout(cleanup, PICKER_TIMEOUT_MS); + + await MediaLibrary.presentPermissionsPickerAsync(); + }, [dispatch, reloadLocal]); +}; + +export default useSelectMorePhotos; diff --git a/src/screens/PhotosScreen/index.tsx b/src/screens/PhotosScreen/index.tsx index bd925ffad..400534919 100644 --- a/src/screens/PhotosScreen/index.tsx +++ b/src/screens/PhotosScreen/index.tsx @@ -20,6 +20,7 @@ import { photoPermissionService } from '../../services/photos/photoPermissionSer import { NotificationType } from '../../types'; import ActionProgressModal from './components/ActionProgressModal'; import BackupDisabledBanner from './components/BackupDisabledBanner'; +import LimitedAccessBanner from './components/LimitedAccessBanner'; import MoreActionsBottomSheet from './components/MoreActionsBottomSheet'; import PhotosHeader from './components/PhotosHeader'; import PhotosLockedOverlay from './components/PhotosLockedOverlay'; @@ -30,6 +31,7 @@ import EnableBackupBottomSheet from './EnableBackupBottomSheet'; import { usePhotoActions } from './hooks/usePhotoActions'; import { usePhotoSelection } from './hooks/usePhotoSelection'; import { usePhotosTimeline } from './hooks/usePhotosTimeline'; +import useSelectMorePhotos from './hooks/useSelectMorePhotos'; import { PhotosAccessState, TimelinePhotoItem } from './types'; const PhotosScreen = (): JSX.Element => { @@ -85,8 +87,14 @@ const PhotosScreen = (): JSX.Element => { const handleSelectPress = useCallback(() => selection.enterSelectMode(), [selection]); const handleEnableBackup = useCallback(() => setIsEnableBackupSheetOpen(true), []); - const listHeader = - accessState.type === 'backup-off' ? : undefined; + + const handleSelectMorePhotos = useSelectMorePhotos(reloadLocal); + + const listHeader = useMemo(() => { + if (accessState.type === 'backup-off') return ; + if (permissionStatus === 'limited') return ; + return undefined; + }, [accessState.type, permissionStatus, handleEnableBackup, handleSelectMorePhotos]); const handlePausePress = useCallback(() => { dispatch(pauseBackupThunk()); diff --git a/src/services/photos/photoPermissionService.ts b/src/services/photos/photoPermissionService.ts index 3791285e7..8731c534e 100644 --- a/src/services/photos/photoPermissionService.ts +++ b/src/services/photos/photoPermissionService.ts @@ -1,4 +1,5 @@ import * as MediaLibrary from 'expo-media-library'; +import { logger } from '../common'; export type PhotoPermissionStatus = 'granted' | 'limited' | 'denied' | 'undetermined'; @@ -21,7 +22,45 @@ export const photoPermissionService = { }, async requestPermission(): Promise { - const { status, accessPrivileges } = await MediaLibrary.requestPermissionsAsync(); - return mapPermission(status, accessPrivileges); + // On iOS, selecting "Limited Photos" can hang requestPermissionsAsync (inline + // picker never dismisses on some versions). iOS sets getPermissionsAsync() to + // 'limited' immediately on tap, so polling detects it in ~1s. + return new Promise((resolve) => { + const MAX_POLL_ATTEMPTS = 30; + let resolved = false; + let attempts = 0; + + const resolvePermission = (result: PhotoPermissionStatus) => { + if (resolved) return; + resolved = true; + resolve(result); + }; + + const poll = async () => { + if (resolved) return; + attempts += 1; + try { + const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync(); + const current = mapPermission(status, accessPrivileges); + if (current !== 'undetermined' || attempts >= MAX_POLL_ATTEMPTS) { + resolvePermission(current); + return; + } + } catch (error) { + logger.error('[photoPermissionService] getPermissionsAsync failed during poll', { error }); + if (attempts >= MAX_POLL_ATTEMPTS) { + resolvePermission('undetermined'); + return; + } + } + setTimeout(poll, 1000); + }; + + MediaLibrary.requestPermissionsAsync() + .then(({ status, accessPrivileges }) => resolvePermission(mapPermission(status, accessPrivileges))) + .catch((error) => logger.error('[photoPermissionService] requestPermissionsAsync failed', { error })); + + setTimeout(poll, 1000); + }); }, }; diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts index ad6b844cc..854a5a036 100644 --- a/src/store/slices/photos/index.spec.ts +++ b/src/store/slices/photos/index.spec.ts @@ -117,7 +117,11 @@ describe('photos slice', () => { // Re-set default implementations after reset clears them mockAsyncStorage.saveItem.mockResolvedValue(undefined); mockAsyncStorage.getItem.mockResolvedValue(null); - mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ deviceId: 'mock-device-id', plainName: 'Mock Device', bucket: 'mock-photos-bucket' }); + mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ + deviceId: 'mock-device-id', + plainName: 'Mock Device', + bucket: 'mock-photos-bucket', + }); mockScanner.scanAll.mockResolvedValue([]); mockScanner.getAssetsByIds.mockResolvedValue([]); mockUploadQueue.start.mockResolvedValue(undefined); @@ -249,6 +253,7 @@ describe('photos slice', () => { }); test('when the user enables backup but denies permission, then backup stays off and the denial is saved', async () => { + mockPermissionService.getStatus.mockResolvedValueOnce('undetermined'); mockPermissionService.requestPermission.mockResolvedValueOnce('denied'); const store = makeStore(); @@ -261,6 +266,7 @@ describe('photos slice', () => { }); test('when the user enables backup but permission status is undetermined, then backup stays off', async () => { + mockPermissionService.getStatus.mockResolvedValueOnce('undetermined'); mockPermissionService.requestPermission.mockResolvedValueOnce('undetermined'); const store = makeStore(); @@ -302,6 +308,7 @@ describe('photos slice', () => { test('when the permission check runs and the user has revoked access, then backup is automatically disabled', async () => { mockPermissionService.requestPermission.mockResolvedValueOnce('granted'); + mockPermissionService.getStatus.mockResolvedValueOnce('undetermined'); mockPermissionService.getStatus.mockResolvedValueOnce('denied'); const store = makeStore(); @@ -329,6 +336,7 @@ describe('photos slice', () => { test('when the permission check runs and the user has limited access, then backup continues running', async () => { mockPermissionService.requestPermission.mockResolvedValueOnce('granted'); + mockPermissionService.getStatus.mockResolvedValueOnce('undetermined'); mockPermissionService.getStatus.mockResolvedValueOnce('granted'); mockPermissionService.getStatus.mockResolvedValueOnce('limited'); @@ -352,7 +360,11 @@ describe('photos slice', () => { }); test('when the device is first registered for backup, then a new device identifier is created and the photos bucket is stored', async () => { - mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValueOnce({ deviceId: 'new-device-id', plainName: 'New Device', bucket: 'mock-photos-bucket' }); + mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValueOnce({ + deviceId: 'new-device-id', + plainName: 'New Device', + bucket: 'mock-photos-bucket', + }); const store = makeStore(); await store.dispatch(initDeviceIdThunk()); @@ -362,7 +374,11 @@ describe('photos slice', () => { }); test('when the device was already registered for backup, then the existing identifier is reused', async () => { - mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValueOnce({ deviceId: 'existing-device-id', plainName: 'Existing Device', bucket: 'mock-photos-bucket' }); + mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValueOnce({ + deviceId: 'existing-device-id', + plainName: 'Existing Device', + bucket: 'mock-photos-bucket', + }); const store = makeStore(); await store.dispatch(initDeviceIdThunk()); @@ -409,11 +425,9 @@ describe('photos slice', () => { }); test('when no photos are found on the device, then zero photos are pending and the status returns to idle', async () => { - mockPermissionService.requestPermission.mockResolvedValueOnce('granted'); - const store = makeStore(); - await store.dispatch(enableBackupThunk()); - jest.clearAllMocks(); + store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); + mockScanner.scanAll.mockResolvedValueOnce([] as never); mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: [], editedAssets: [] } as never); mockPhotosLocalDB.init.mockResolvedValueOnce(undefined); @@ -447,7 +461,11 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); mockPermissionService.getStatus.mockResolvedValueOnce('granted'); - mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValueOnce({ deviceId: 'device-id', plainName: 'Device', bucket: 'mock-photos-bucket' }); + mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValueOnce({ + deviceId: 'device-id', + plainName: 'Device', + bucket: 'mock-photos-bucket', + }); const assets = Array.from({ length: 5 }, (_, i) => ({ id: `asset-${i}` })); mockScanner.scanAll.mockResolvedValueOnce(assets as never); mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: assets, editedAssets: [] } as never); @@ -597,7 +615,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(forceRefreshThunk()); @@ -617,7 +640,11 @@ describe('photos slice', () => { describe('backup cycle', () => { test('when the backup cycle runs, then cloud history sync and discovery both run', async () => { - mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ deviceId: 'device-id', plainName: 'Device', bucket: 'mock-photos-bucket' }); + mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ + deviceId: 'device-id', + plainName: 'Device', + bucket: 'mock-photos-bucket', + }); const store = makeStore(); store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); @@ -630,7 +657,11 @@ describe('photos slice', () => { }); test('when the backup cycle runs while paused, then discovery runs but the upload is skipped and sync status is paused', async () => { - mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ deviceId: 'device-id', plainName: 'Device', bucket: 'mock-photos-bucket' }); + mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ + deviceId: 'device-id', + plainName: 'Device', + bucket: 'mock-photos-bucket', + }); mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: [{ id: 'a1' } as never], editedAssets: [] }); const store = makeStore(); @@ -694,7 +725,11 @@ describe('photos slice', () => { describe('resuming backup', () => { test('when the user resumes the backup, then is paused flag clears and the backup cycle is dispatched', async () => { - mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ deviceId: 'device-id', plainName: 'Device', bucket: 'mock-photos-bucket' }); + mockPhotoDeviceManager.ensureDeviceFolder.mockResolvedValue({ + deviceId: 'device-id', + plainName: 'Device', + bucket: 'mock-photos-bucket', + }); mockPermissionService.getStatus.mockResolvedValue('granted'); const store = makeStore(); @@ -731,7 +766,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(runUploadThunk()); @@ -748,7 +788,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(runUploadThunk()); @@ -764,7 +809,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(runUploadThunk()); @@ -783,7 +833,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(runUploadThunk()); @@ -801,7 +856,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(runUploadThunk()); @@ -814,7 +874,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(runUploadThunk()); @@ -829,7 +894,12 @@ describe('photos slice', () => { const store = makeStore(); store.dispatch( - photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1', photosBucket: 'photos-bucket-1' }), + photosSlice.actions.setState({ + enabled: true, + permissionStatus: 'granted', + deviceId: 'device-1', + photosBucket: 'photos-bucket-1', + }), ); await store.dispatch(runUploadThunk()); diff --git a/src/store/slices/photos/index.ts b/src/store/slices/photos/index.ts index 1110eb777..489f08495 100644 --- a/src/store/slices/photos/index.ts +++ b/src/store/slices/photos/index.ts @@ -122,16 +122,23 @@ export const enableBackupThunk = createAsyncThunk< void, { state: RootState } >('photos/enableBackup', async (_, { getState, dispatch }) => { - const permissionStatus = await photoPermissionService.requestPermission(); - const isGranted = isPermissionActive(permissionStatus); + const currentStatus = await photoPermissionService.getStatus(); + + let permissionStatus: PhotoPermissionStatus; + if (isPermissionActive(currentStatus)) { + permissionStatus = currentStatus; + } else { + permissionStatus = await photoPermissionService.requestPermission(); + } + const isGranted = isPermissionActive(permissionStatus); const state = getState().photos; const updated: PhotosState = { ...state, enabled: isGranted, permissionStatus }; dispatch(photosSlice.actions.setState({ enabled: isGranted, permissionStatus })); + await persistPhotosSettings(updated); if (isGranted) { - await dispatch(initDeviceIdThunk()); dispatch(runBackupCycleThunk()); }