Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions assets/lang/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand 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',
Expand Down
16 changes: 10 additions & 6 deletions src/screens/PhotosScreen/EnableBackupBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
};

Expand Down
31 changes: 31 additions & 0 deletions src/screens/PhotosScreen/components/LimitedAccessBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[tailwind('flex-row items-start px-4 py-3'), { gap: 8, backgroundColor: getColor('bg-primary-10') }]}>
<ImageIcon size={16} color={getColor('text-primary')} weight="fill" />
<TouchableOpacity onPress={onSelectMorePress} style={tailwind('flex-1')} activeOpacity={0.7}>
<AppText style={[tailwind('text-xs'), { color: getColor('text-primary-dark'), lineHeight: 14.4 }]}>
{strings.screens.photos.limitedAccess.message}
<AppText medium style={[tailwind('text-xs'), { color: getColor('text-primary-dark'), lineHeight: 14.4 }]}>
{strings.screens.photos.limitedAccess.selectMoreCta}
</AppText>
</AppText>
</TouchableOpacity>
</View>
);
};

export default LimitedAccessBanner;
58 changes: 58 additions & 0 deletions src/screens/PhotosScreen/hooks/useSelectMorePhotos.ts
Original file line number Diff line number Diff line change
@@ -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<void>): (() => Promise<void>) => {
const dispatch = useAppDispatch();
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const listenerRef = useRef<ReturnType<typeof MediaLibrary.addListener> | 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;
12 changes: 10 additions & 2 deletions src/screens/PhotosScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 => {
Expand Down Expand Up @@ -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' ? <BackupDisabledBanner onEnablePress={handleEnableBackup} /> : undefined;

const handleSelectMorePhotos = useSelectMorePhotos(reloadLocal);

const listHeader = useMemo(() => {
if (accessState.type === 'backup-off') return <BackupDisabledBanner onEnablePress={handleEnableBackup} />;
if (permissionStatus === 'limited') return <LimitedAccessBanner onSelectMorePress={handleSelectMorePhotos} />;
return undefined;
}, [accessState.type, permissionStatus, handleEnableBackup, handleSelectMorePhotos]);

const handlePausePress = useCallback(() => {
dispatch(pauseBackupThunk());
Expand Down
43 changes: 41 additions & 2 deletions src/services/photos/photoPermissionService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as MediaLibrary from 'expo-media-library';
import { logger } from '../common';

export type PhotoPermissionStatus = 'granted' | 'limited' | 'denied' | 'undetermined';

Expand All @@ -21,7 +22,45 @@ export const photoPermissionService = {
},

async requestPermission(): Promise<PhotoPermissionStatus> {
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<PhotoPermissionStatus>((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);
});
},
};
Loading
Loading