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());
}