From 0058e2ba50e76f4a23f45d98f5e26c4452576d01 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 7 May 2026 09:30:32 +0200 Subject: [PATCH 1/4] Implemented Photos cloud asset management and synchronization --- .../PhotosScreen/components/PhotoItem.tsx | 212 ++++++++++++------ .../components/PhotosTimeline.tsx | 1 + .../hooks/useCloudThumbnail.spec.ts | 180 +++++++++++++++ .../PhotosScreen/hooks/useCloudThumbnail.ts | 57 +++++ .../PhotosScreen/hooks/usePhotosTimeline.ts | 66 ++++-- src/screens/PhotosScreen/mockData.ts | 58 ----- src/screens/PhotosScreen/types.ts | 23 +- .../utils/photoTimelineGroups.spec.ts | 86 ++++++- .../PhotosScreen/utils/photoTimelineGroups.ts | 44 +++- src/services/photos/PhotoBackupFolders.ts | 8 + src/services/photos/PhotoCloudBrowser.spec.ts | 195 ++++++++++++++++ src/services/photos/PhotoCloudBrowser.ts | 119 ++++++++++ src/services/photos/PhotoUploadQueue.ts | 14 +- .../photos/database/photosLocalDB.spec.ts | 156 ++++++++++++- src/services/photos/database/photosLocalDB.ts | 107 +++++++++ .../photos/database/tables/asset_sync.ts | 1 + .../photos/database/tables/cloud_asset.ts | 70 ++++++ src/store/slices/auth/index.ts | 2 + src/store/slices/photos/index.spec.ts | 65 ++++++ src/store/slices/photos/index.ts | 56 ++++- 20 files changed, 1342 insertions(+), 178 deletions(-) create mode 100644 src/screens/PhotosScreen/hooks/useCloudThumbnail.spec.ts create mode 100644 src/screens/PhotosScreen/hooks/useCloudThumbnail.ts delete mode 100644 src/screens/PhotosScreen/mockData.ts create mode 100644 src/services/photos/PhotoCloudBrowser.spec.ts create mode 100644 src/services/photos/PhotoCloudBrowser.ts create mode 100644 src/services/photos/database/tables/cloud_asset.ts diff --git a/src/screens/PhotosScreen/components/PhotoItem.tsx b/src/screens/PhotosScreen/components/PhotoItem.tsx index 10677d3a4..839d08c76 100644 --- a/src/screens/PhotosScreen/components/PhotoItem.tsx +++ b/src/screens/PhotosScreen/components/PhotoItem.tsx @@ -1,12 +1,13 @@ import { LinearGradient } from 'expo-linear-gradient'; -import { ArrowUpIcon, CloudSlashIcon } from 'phosphor-react-native'; +import { ArrowUpIcon, CloudIcon, CloudSlashIcon, ImageIcon } from 'phosphor-react-native'; import { memo, useCallback, useEffect, useRef } from 'react'; import { Animated, Easing, Image, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Circle } from 'react-native-progress'; import AppText from 'src/components/AppText'; import useGetColor from 'src/hooks/useColor'; import { useTailwind } from 'tailwind-rn'; -import { PhotoItem as PhotoItemType } from '../types'; +import { useCloudThumbnail } from '../hooks/useCloudThumbnail'; +import { CloudPhotoItem, PhotoItem as PhotoItemType, TimelinePhotoItem } from '../types'; const SkeletonCell = (): JSX.Element => { const getColor = useGetColor(); @@ -28,94 +29,159 @@ const SkeletonCell = (): JSX.Element => { ); }; -interface PhotoItemProps { - item: PhotoItemType; +const UploadProgressRing = ({ progress, color }: { progress: number; color: string }): JSX.Element => ( + + + + + + +); + +interface CellProps { isSelectMode?: boolean; isSelected?: boolean; onPress?: (id: string) => void; onLongPress?: (id: string) => void; } -const PhotoItem = memo(({ item, isSelectMode, isSelected, onPress, onLongPress }: PhotoItemProps): JSX.Element => { +const SelectOverlay = ({ + isSelectMode, + isSelected, +}: Pick): JSX.Element | null => { const tailwind = useTailwind(); const getColor = useGetColor(); - const handlePress = useCallback(() => onPress?.(item.id), [onPress, item.id]); - const handleLongPress = useCallback(() => onLongPress?.(item.id), [onLongPress, item.id]); - - if (item.backupState === 'loading' || !item.uri) { - return ; + if (!isSelectMode && !isSelected) { + return null; } - const containerStyle = [styles.container, { backgroundColor: getColor('bg-gray-1') }]; - return ( - - - - {(item.backupState === 'not-backed' || item.backupState === 'uploading') && ( - - - {item.backupState === 'not-backed' ? ( - - ) : ( - - )} - - )} - - {item.mediaType === 'video' && item.duration && ( - - + {isSelected && } + + ); +}; + +const LocalPhotoCell = memo( + ({ item, isSelectMode, isSelected, onPress, onLongPress }: CellProps & { item: PhotoItemType }): JSX.Element => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + + const handlePress = useCallback(() => onPress?.(item.id), [onPress, item.id]); + const handleLongPress = useCallback(() => onLongPress?.(item.id), [onLongPress, item.id]); + + if (item.backupState === 'loading' || !item.uri) { + return ; + } + + const containerStyle = [styles.container, { backgroundColor: getColor('bg-gray-1') }]; + + return ( + + + + {(item.backupState === 'not-backed' || item.backupState === 'uploading') && ( + + + {item.backupState === 'not-backed' ? ( + + ) : ( + + )} + + )} + + {item.mediaType === 'video' && item.duration && ( + + + + {item.duration} + + + )} + + + + ); + }, +); + +const CloudPhotoCell = memo( + ({ item, isSelectMode, isSelected, onPress, onLongPress }: CellProps & { item: CloudPhotoItem }): JSX.Element => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + const { uri: thumbnailUri, onImageError } = useCloudThumbnail(item); + + const handlePress = useCallback(() => { + console.log('[CloudPhotoCell] press', JSON.stringify(item, null, 2)); + onPress?.(item.id); + }, [onPress, item.id]); + const handleLongPress = useCallback(() => onLongPress?.(item.id), [onLongPress, item.id]); + + const containerStyle = [styles.container, { backgroundColor: getColor('bg-gray-1') }]; + + return ( + + {thumbnailUri ? ( + - - {item.duration} - - - )} - - {(isSelectMode || isSelected) && ( - - {isSelected && } + ) : ( + + + + )} + + + - )} - - ); -}); -const UploadProgressRing = ({ progress, color }: { progress: number; color: string }): JSX.Element => ( - - - - - - + + + ); + }, ); +interface PhotoItemProps extends CellProps { + item: TimelinePhotoItem; +} + +const PhotoItem = memo(({ item, ...rest }: PhotoItemProps): JSX.Element => { + if (item.type === 'cloud-only') { + return ; + } + return ; +}); + const styles = StyleSheet.create({ container: { flex: 1, diff --git a/src/screens/PhotosScreen/components/PhotosTimeline.tsx b/src/screens/PhotosScreen/components/PhotosTimeline.tsx index 52c71d7b6..057d1ff8d 100644 --- a/src/screens/PhotosScreen/components/PhotosTimeline.tsx +++ b/src/screens/PhotosScreen/components/PhotosTimeline.tsx @@ -20,6 +20,7 @@ const SKELETON_GROUP: TimelineDateGroup = { label: '', photos: Array.from({ length: 12 }, (_, i) => ({ id: `__skeleton_${i}__`, + type: 'local' as const, backupState: 'loading' as PhotoBackupState, mediaType: 'photo' as const, })), diff --git a/src/screens/PhotosScreen/hooks/useCloudThumbnail.spec.ts b/src/screens/PhotosScreen/hooks/useCloudThumbnail.spec.ts new file mode 100644 index 000000000..0d6653978 --- /dev/null +++ b/src/screens/PhotosScreen/hooks/useCloudThumbnail.spec.ts @@ -0,0 +1,180 @@ +import { driveFileService } from '@internxt-mobile/services/drive/file'; +import { act, renderHook } from '@testing-library/react-native'; +import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { CloudPhotoItem } from '../types'; +import { useCloudThumbnail } from './useCloudThumbnail'; + +jest.mock('@internxt-mobile/services/drive/file', () => ({ + driveFileService: { + getThumbnail: jest.fn(), + }, +})); + +jest.mock('src/services/photos/database/photosLocalDB', () => ({ + photosLocalDB: { + setCloudThumbnailPath: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('src/store/hooks', () => ({ + useAppSelector: jest.fn().mockReturnValue({ id: 'user-1', email: 'test@example.com' }), +})); + +const mockDriveFileService = driveFileService as jest.Mocked; +const mockPhotosLocalDB = photosLocalDB as jest.Mocked; + +const makeCloudItem = (overrides: Partial = {}): CloudPhotoItem => ({ + id: 'remote-1', + type: 'cloud-only', + mediaType: 'photo', + thumbnailPath: null, + thumbnailBucketId: 'bucket-1', + thumbnailBucketFile: 'file-1', + thumbnailType: 'jpg', + deviceId: 'device-1', + createdAt: 1718000000000, + fileName: 'photo.jpg', + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockPhotosLocalDB.setCloudThumbnailPath.mockResolvedValue(undefined); +}); + +describe('useCloudThumbnail', () => { + test('when the item already has a local thumbnail path, then the drive API is not called', async () => { + const item = makeCloudItem({ thumbnailPath: '/local/thumb.jpg' }); + + const { result } = renderHook(() => useCloudThumbnail(item)); + + expect(result.current.uri).toBe('/local/thumb.jpg'); + expect(mockDriveFileService.getThumbnail).not.toHaveBeenCalled(); + }); + + test('when the item has no local path and no bucket info, then the drive API is not called', async () => { + const item = makeCloudItem({ thumbnailPath: null, thumbnailBucketId: null, thumbnailBucketFile: null }); + + renderHook(() => useCloudThumbnail(item)); + + expect(mockDriveFileService.getThumbnail).not.toHaveBeenCalled(); + }); + + test('when the item has no local path but has bucket info, then the thumbnail is downloaded and returned', async () => { + mockDriveFileService.getThumbnail.mockResolvedValueOnce({ uri: '/downloaded/thumb.jpg' } as never); + + const item = makeCloudItem({ thumbnailPath: null }); + + const { result } = renderHook(() => useCloudThumbnail(item)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.uri).toBe('/downloaded/thumb.jpg'); + expect(mockDriveFileService.getThumbnail).toHaveBeenCalledTimes(1); + }); + + test('when the thumbnail is successfully downloaded, then the path is persisted to the local database', async () => { + mockDriveFileService.getThumbnail.mockResolvedValueOnce({ uri: '/downloaded/thumb.jpg' } as never); + + const item = makeCloudItem({ thumbnailPath: null }); + + renderHook(() => useCloudThumbnail(item)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockPhotosLocalDB.setCloudThumbnailPath).toHaveBeenCalledWith('remote-1', '/downloaded/thumb.jpg'); + }); + + test('when the thumbnail download fails, then null is returned and no error is thrown', async () => { + mockDriveFileService.getThumbnail.mockRejectedValueOnce(new Error('network error')); + + const item = makeCloudItem({ thumbnailPath: null }); + + const { result } = renderHook(() => useCloudThumbnail(item)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.uri).toBeNull(); + expect(mockPhotosLocalDB.setCloudThumbnailPath).not.toHaveBeenCalled(); + }); + + test('when the list recycles the cell and the new item has a different persisted path, then the correct path is shown', async () => { + const itemA = makeCloudItem({ id: 'remote-1', thumbnailPath: '/thumb-a.jpg' }); + const itemB = makeCloudItem({ id: 'remote-2', thumbnailPath: '/thumb-b.jpg' }); + + const { result, rerender } = renderHook(({ item }) => useCloudThumbnail(item), { + initialProps: { item: itemA }, + }); + + expect(result.current.uri).toBe('/thumb-a.jpg'); + + await act(async () => { + rerender({ item: itemB }); + }); + + expect(result.current.uri).toBe('/thumb-b.jpg'); + expect(mockDriveFileService.getThumbnail).not.toHaveBeenCalled(); + }); + + test('when the list recycles the cell and the new item has no path, then a new download is triggered', async () => { + const itemA = makeCloudItem({ id: 'remote-1', thumbnailPath: '/thumb-a.jpg' }); + const itemB = makeCloudItem({ id: 'remote-2', thumbnailPath: null }); + mockDriveFileService.getThumbnail.mockResolvedValueOnce({ uri: '/downloaded/thumb-b.jpg' } as never); + + const { result, rerender } = renderHook(({ item }) => useCloudThumbnail(item), { + initialProps: { item: itemA }, + }); + + expect(result.current.uri).toBe('/thumb-a.jpg'); + + await act(async () => { + rerender({ item: itemB }); + }); + + expect(result.current.uri).toBe('/downloaded/thumb-b.jpg'); + expect(mockDriveFileService.getThumbnail).toHaveBeenCalledTimes(1); + }); + + test('when the component unmounts before the download finishes, then the state is not updated', async () => { + let resolveThumbnail!: (value: { uri: string }) => void; + mockDriveFileService.getThumbnail.mockReturnValueOnce( + new Promise((resolve) => { + resolveThumbnail = resolve; + }) as never, + ); + + const item = makeCloudItem({ thumbnailPath: null }); + + const { result, unmount } = renderHook(() => useCloudThumbnail(item)); + + unmount(); + + await act(async () => { + resolveThumbnail({ uri: '/downloaded/thumb.jpg' }); + }); + + expect(result.current.uri).toBeNull(); + expect(mockPhotosLocalDB.setCloudThumbnailPath).not.toHaveBeenCalled(); + }); + + test('when the image fails to load, then the stale path is cleared from the database and uri becomes null', async () => { + const item = makeCloudItem({ thumbnailPath: '/stale/thumb.jpg', thumbnailBucketId: 'bucket-1', thumbnailBucketFile: 'file-1' }); + + const { result } = renderHook(() => useCloudThumbnail(item)); + + expect(result.current.uri).toBe('/stale/thumb.jpg'); + + await act(async () => { + result.current.onImageError(); + }); + + expect(result.current.uri).toBeNull(); + expect(mockPhotosLocalDB.setCloudThumbnailPath).toHaveBeenCalledWith('remote-1', null); + }); +}); diff --git a/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts b/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts new file mode 100644 index 000000000..f42301e35 --- /dev/null +++ b/src/screens/PhotosScreen/hooks/useCloudThumbnail.ts @@ -0,0 +1,57 @@ +import { driveFileService } from '@internxt-mobile/services/drive/file'; +import { useCallback, useEffect, useState } from 'react'; +import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { useAppSelector } from 'src/store/hooks'; +import { CloudPhotoItem } from '../types'; + +export const useCloudThumbnail = (item: CloudPhotoItem): { uri: string | null; onImageError: () => void } => { + const [localPath, setLocalPath] = useState(item.thumbnailPath); + const user = useAppSelector((state) => state.auth.user); + + const onImageError = useCallback(() => { + photosLocalDB.setCloudThumbnailPath(item.id, null); + setLocalPath(null); + }, [item.id]); + + useEffect(() => { + // FlashList recycles cells: reset to the new item's persisted path before checking + setLocalPath(item.thumbnailPath); + + const { thumbnailPath, thumbnailBucketId, thumbnailBucketFile } = item; + if (thumbnailPath || !thumbnailBucketId || !thumbnailBucketFile || !user) { + return; + } + + let cancelled = false; + + const fetchThumbnail = async () => { + try { + const result = await driveFileService.getThumbnail( + { + bucketId: thumbnailBucketId, + bucketFile: thumbnailBucketFile, + bucket_id: thumbnailBucketId, + bucket_file: thumbnailBucketFile, + type: item.thumbnailType ?? 'jpg', + }, + user, + ); + if (cancelled) { + return; + } + setLocalPath(result.uri); + photosLocalDB.setCloudThumbnailPath(item.id, result.uri); + } catch (error) { + console.error(`Failed to fetch thumbnail for cloud photo ${item.id} ${error}`); + } + }; + + fetchThumbnail(); + + return () => { + cancelled = true; + }; + }, [item.id]); + + return { uri: localPath, onImageError }; +}; diff --git a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts index 1e959a8c6..9cd2ad095 100644 --- a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts +++ b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts @@ -4,7 +4,13 @@ import { AppState } from 'react-native'; import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; import { useAppSelector } from 'src/store/hooks'; import { TimelineDateGroup } from '../components/PhotosTimeline'; -import { groupAssetsByDate, getGroupSyncStatus } from '../utils/photoTimelineGroups'; +import { CloudPhotoItem } from '../types'; +import { + cloudEntryToPhotoItem, + getGroupSyncStatus, + groupAssetsByDate, + mergeCloudIntoGroups, +} from '../utils/photoTimelineGroups'; const PAGE_SIZE = 200; const MEDIA_TYPES = [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video]; @@ -19,15 +25,15 @@ export const usePhotosTimeline = (): PhotosTimelineResult => { const [assets, setAssets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [syncedIds, setSyncedIds] = useState>(new Set()); + const [cloudItems, setCloudItems] = useState([]); const cursorRef = useRef(undefined); const hasMoreRef = useRef(true); const isLoadingMoreRef = useRef(false); const appStateRef = useRef(AppState.currentState); - const { syncStatus, uploadingAssetIds, sessionTotalAssets, sessionUploadedAssets } = useAppSelector( - (state) => state.photos, - ); + const { syncStatus, uploadingAssetIds, sessionTotalAssets, sessionUploadedAssets, lastSyncTimestamp } = + useAppSelector((state) => state.photos); const fetchLocalPage = useCallback(async (after?: string): Promise> => { return MediaLibrary.getAssetsAsync({ @@ -38,17 +44,24 @@ export const usePhotosTimeline = (): PhotosTimelineResult => { }); }, []); + const applyPage = useCallback( + (page: MediaLibrary.PagedInfo, { replace }: { replace: boolean }) => { + setAssets((prev) => (replace ? page.assets : [...prev, ...page.assets])); + cursorRef.current = page.hasNextPage ? page.endCursor : undefined; + hasMoreRef.current = page.hasNextPage; + }, + [], + ); + const loadNextPage = useCallback(async () => { if (isLoadingMoreRef.current || !hasMoreRef.current || !cursorRef.current) return; isLoadingMoreRef.current = true; setIsLoading(true); const page = await fetchLocalPage(cursorRef.current); - setAssets((prev) => [...prev, ...page.assets]); - cursorRef.current = page.hasNextPage ? page.endCursor : undefined; - hasMoreRef.current = page.hasNextPage; + applyPage(page, { replace: false }); isLoadingMoreRef.current = false; setIsLoading(false); - }, [fetchLocalPage]); + }, [fetchLocalPage, applyPage]); const refreshSyncStatusFromDB = useCallback(async () => { if (assets.length === 0) { @@ -64,18 +77,14 @@ export const usePhotosTimeline = (): PhotosTimelineResult => { cursorRef.current = undefined; hasMoreRef.current = true; const page = await fetchLocalPage(); - setAssets(page.assets); - cursorRef.current = page.hasNextPage ? page.endCursor : undefined; - hasMoreRef.current = page.hasNextPage; - }, [fetchLocalPage]); + applyPage(page, { replace: true }); + }, [fetchLocalPage, applyPage]); useEffect(() => { const loadFirstPage = async () => { try { const page = await fetchLocalPage(); - setAssets(page.assets); - cursorRef.current = page.hasNextPage ? page.endCursor : undefined; - hasMoreRef.current = page.hasNextPage; + applyPage(page, { replace: true }); } finally { setIsLoading(false); } @@ -95,20 +104,33 @@ export const usePhotosTimeline = (): PhotosTimelineResult => { useEffect(() => { refreshSyncStatusFromDB(); - }, [refreshSyncStatusFromDB, syncStatus]); + }, [refreshSyncStatusFromDB, syncStatus, sessionUploadedAssets]); - const uploadingIdSet = useMemo(() => new Set(uploadingAssetIds), [uploadingAssetIds]); + useEffect(() => { + const loadCloudAssets = async () => { + await photosLocalDB.init(); + const [allCloud, syncedRemoteIds] = await Promise.all([ + photosLocalDB.getAllCloudAssets(), + photosLocalDB.getSyncedRemoteFileIds(), + ]); + const deduplicated = allCloud.filter((entry) => !syncedRemoteIds.has(entry.remoteFileId)); + setCloudItems(deduplicated.map(cloudEntryToPhotoItem)); + }; + loadCloudAssets(); + }, [lastSyncTimestamp]); - const remainingCount = Math.max(0, sessionTotalAssets - sessionUploadedAssets); - const backupProgress = sessionTotalAssets > 0 ? sessionUploadedAssets / sessionTotalAssets : undefined; + const uploadingIdSet = useMemo(() => new Set(uploadingAssetIds), [uploadingAssetIds]); const timelineDateGroups = useMemo(() => { - const dateGroups = groupAssetsByDate(assets, syncedIds, uploadingIdSet); - return dateGroups.map((group) => ({ + const remainingCount = Math.max(0, sessionTotalAssets - sessionUploadedAssets); + const backupProgress = sessionTotalAssets > 0 ? sessionUploadedAssets / sessionTotalAssets : undefined; + const localGroups = groupAssetsByDate(assets, syncedIds, uploadingIdSet); + const mergedGroups = mergeCloudIntoGroups(localGroups, cloudItems); + return mergedGroups.map((group) => ({ group, syncStatus: getGroupSyncStatus(group, syncStatus, remainingCount, backupProgress), })); - }, [assets, syncedIds, uploadingIdSet, syncStatus, remainingCount, backupProgress]); + }, [assets, syncedIds, uploadingIdSet, cloudItems, syncStatus, sessionTotalAssets, sessionUploadedAssets]); return { timelineDateGroups, isLoading, loadNextPage }; }; diff --git a/src/screens/PhotosScreen/mockData.ts b/src/screens/PhotosScreen/mockData.ts deleted file mode 100644 index 89d578217..000000000 --- a/src/screens/PhotosScreen/mockData.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { PhotoDateGroup, PhotoItem } from './types'; - -const MOCK_URIS = [ - 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400', - 'https://images.unsplash.com/photo-1501854140801-50d01698950b?w=400', - 'https://images.unsplash.com/photo-1470770841072-f978cf4d019e?w=400', - 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=400', - 'https://images.unsplash.com/photo-1518173946687-a4c8892bbd9f?w=400', - 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=400', - 'https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=400', - 'https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=400', - 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=400', -]; - -const makePhotos = (prefix: string, count: number, backupState: PhotoItem['backupState'] = 'backed'): PhotoItem[] => - Array.from({ length: count }, (_, i) => ({ - id: `${prefix}-${i}`, - uri: MOCK_URIS[i % MOCK_URIS.length], - backupState, - mediaType: (i % 5 === 0 ? 'video' : 'photo') as PhotoItem['mediaType'], - duration: i % 5 === 0 ? '0:24' : undefined, - })); - -const MOCK_GROUP: PhotoDateGroup = { - id: 'today', - label: 'Today', - photos: Array.from({ length: 21 }, (_, i) => ({ - id: `loading-${i}`, - backupState: 'loading' as const, - mediaType: 'photo' as const, - })), -}; - -const MOCK_GROUP_BACKING_UP: PhotoDateGroup = { - id: 'today-backing-up', - label: 'Today', - photos: Array.from({ length: 21 }, (_, i) => ({ - id: `backup-${i}`, - uri: MOCK_URIS[i % MOCK_URIS.length], - backupState: (i < 3 ? 'uploading' : i % 4 === 0 ? 'not-backed' : 'backed') as PhotoItem['backupState'], - uploadProgress: i < 3 ? [0.3, 0.65, 0.9][i] : undefined, - mediaType: (i % 5 === 0 ? 'video' : 'photo') as PhotoItem['mediaType'], - duration: i % 5 === 0 ? '0:24' : undefined, - })), -}; - -const MOCK_MULTI_DATE_GROUPS: PhotoDateGroup[] = [ - { id: 'today', label: 'Today', photos: makePhotos('today', 12) }, - { id: 'yesterday', label: 'Yesterday', photos: makePhotos('yesterday', 18) }, - { id: '14-apr-2026', label: '14 Apr 2026', photos: makePhotos('14apr', 21) }, - { id: '10-apr-2026', label: '10 Apr 2026', photos: makePhotos('10apr', 15) }, - { id: 'mar-2026', label: 'March 2026', photos: makePhotos('mar', 30) }, - { id: 'feb-2026', label: 'February 2026', photos: makePhotos('feb', 27) }, -]; - -const MOCK_GROUP_WITH_PHOTOS: PhotoDateGroup = MOCK_MULTI_DATE_GROUPS[0]; - -export { MOCK_GROUP, MOCK_GROUP_BACKING_UP, MOCK_GROUP_WITH_PHOTOS, MOCK_MULTI_DATE_GROUPS }; diff --git a/src/screens/PhotosScreen/types.ts b/src/screens/PhotosScreen/types.ts index 2dd6edbca..3bf9173fb 100644 --- a/src/screens/PhotosScreen/types.ts +++ b/src/screens/PhotosScreen/types.ts @@ -3,6 +3,7 @@ export type PhotoMediaType = 'photo' | 'video'; export interface PhotoItem { id: string; + type: 'local'; uri?: string; backupState: PhotoBackupState; mediaType: PhotoMediaType; @@ -10,10 +11,25 @@ export interface PhotoItem { uploadProgress?: number; } +export interface CloudPhotoItem { + id: string; + type: 'cloud-only'; + mediaType: PhotoMediaType; + thumbnailPath: string | null; + thumbnailBucketId: string | null; + thumbnailBucketFile: string | null; + thumbnailType: string | null; + deviceId: string; + createdAt: number; + fileName: string; +} + +export type TimelinePhotoItem = PhotoItem | CloudPhotoItem; + export interface PhotoDateGroup { id: string; label: string; - photos: PhotoItem[]; + photos: TimelinePhotoItem[]; } export type PhotosSyncStatus = @@ -23,7 +39,4 @@ export type PhotosSyncStatus = | { type: 'completed' } | { type: 'synced' }; -export type PhotosAccessState = - | { type: 'available' } - | { type: 'backup-off' } - | { type: 'photos-locked' }; +export type PhotosAccessState = { type: 'available' } | { type: 'backup-off' } | { type: 'photos-locked' }; diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts index bdeb97fba..e33203d54 100644 --- a/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts +++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts @@ -2,13 +2,16 @@ import * as MediaLibrary from 'expo-media-library'; import { GroupSyncStatus } from '../components/GroupHeader/PhotosGroupHeader'; import { TimelineDateGroup } from '../components/PhotosTimeline'; import { PhotoDateGroup, PhotoItem } from '../types'; +import { CloudAssetEntry } from 'src/services/photos/database/photosLocalDB'; import { assetToPhotoItem, buildTimelineItems, + cloudEntryToPhotoItem, formatVideoDuration, getDateLabel, getGroupSyncStatus, groupAssetsByDate, + mergeCloudIntoGroups, } from './photoTimelineGroups'; jest.mock('expo-media-library', () => ({ @@ -33,6 +36,7 @@ const makeAsset = (overrides: Partial = {}): MediaLibrary.As const makePhotoItem = (overrides: Partial = {}): PhotoItem => ({ id: 'asset-1', + type: 'local', uri: 'file:///photo.jpg', backupState: 'not-backed', mediaType: 'photo', @@ -159,7 +163,8 @@ describe('groupAssetsByDate', () => { test('when an asset is synced, then the corresponding photo item has a backed state', () => { const asset = makeAsset({ id: 'a1' }); const groups = groupAssetsByDate([asset], new Set(['a1']), new Set()); - expect(groups[0].photos[0].backupState).toBe('backed'); + const photo = groups[0].photos[0] as import('../types').PhotoItem; + expect(photo.backupState).toBe('backed'); }); }); @@ -178,6 +183,10 @@ describe('getGroupSyncStatus', () => { }); }); + test('when sync status is fetching-cloud, then returns a fetching status', () => { + expect(getGroupSyncStatus(group, 'fetching-cloud', 0, undefined)).toEqual({ type: 'fetching' }); + }); + test('when sync status is idle, then returns a count equal to the number of photos in the group', () => { expect(getGroupSyncStatus(group, 'idle', 0, undefined)).toEqual({ type: 'count', count: 2 }); }); @@ -224,3 +233,78 @@ describe('buildTimelineItems', () => { expect(headerIndices).toHaveLength(0); }); }); + +const makeCloudEntry = (overrides: Partial = {}): CloudAssetEntry => ({ + remoteFileId: 'remote-1', + deviceId: 'device-1', + createdAt: new Date('2024-06-15T12:00:00').getTime(), + fileName: 'photo.jpg', + fileSize: 1024, + thumbnailPath: null, + thumbnailBucketId: null, + thumbnailBucketFile: null, + thumbnailType: null, + discoveredAt: Date.now(), + ...overrides, +}); + +describe('cloudEntryToPhotoItem', () => { + test('when the entry has a jpg filename, then media type is photo', () => { + const item = cloudEntryToPhotoItem(makeCloudEntry({ fileName: 'photo.jpg' })); + expect(item.mediaType).toBe('photo'); + }); + + test('when the entry has a video filename, then media type is video', () => { + const item = cloudEntryToPhotoItem(makeCloudEntry({ fileName: 'clip.mp4' })); + expect(item.mediaType).toBe('video'); + }); + + test('when the entry has thumbnail data, then it is preserved in the result', () => { + const item = cloudEntryToPhotoItem( + makeCloudEntry({ thumbnailPath: '/cache/thumb.jpg', thumbnailBucketId: 'bucket-1', thumbnailBucketFile: 'file-1', thumbnailType: 'jpg' }), + ); + expect(item.thumbnailPath).toBe('/cache/thumb.jpg'); + expect(item.thumbnailBucketId).toBe('bucket-1'); + expect(item.thumbnailBucketFile).toBe('file-1'); + expect(item.thumbnailType).toBe('jpg'); + }); + + test('when converted, then type is cloud-only and id matches the remote file id', () => { + const item = cloudEntryToPhotoItem(makeCloudEntry({ remoteFileId: 'abc-123' })); + expect(item.type).toBe('cloud-only'); + expect(item.id).toBe('abc-123'); + }); +}); + +describe('mergeCloudIntoGroups', () => { + test('when there are no cloud items, then the local groups are returned unchanged', () => { + const localGroups = [makeDateGroup()]; + const result = mergeCloudIntoGroups(localGroups, []); + expect(result).toBe(localGroups); + }); + + test('when a cloud item falls on an existing local group date, then it is added to that group', () => { + const createdAt = new Date('2024-06-15T12:00:00').getTime(); + const localGroups = [makeDateGroup({ id: new Date('2024-06-15T12:00:00').toDateString() })]; + const cloudItem = cloudEntryToPhotoItem(makeCloudEntry({ createdAt })); + const result = mergeCloudIntoGroups(localGroups, [cloudItem]); + expect(result[0].photos).toHaveLength(2); + }); + + test('when a cloud item has a date with no matching local group, then a new group is created for it', () => { + const createdAt = new Date('2024-05-01T12:00:00').getTime(); + const localGroups = [makeDateGroup({ id: new Date('2024-06-15T12:00:00').toDateString() })]; + const cloudItem = cloudEntryToPhotoItem(makeCloudEntry({ createdAt })); + const result = mergeCloudIntoGroups(localGroups, [cloudItem]); + expect(result).toHaveLength(2); + }); + + test('when merging groups from different dates, then they are sorted from newest to oldest', () => { + const olderAt = new Date('2024-05-01T12:00:00').getTime(); + const newerAt = new Date('2024-06-15T12:00:00').getTime(); + const localGroups = [makeDateGroup({ id: new Date(olderAt).toDateString() })]; + const cloudItem = cloudEntryToPhotoItem(makeCloudEntry({ createdAt: newerAt })); + const result = mergeCloudIntoGroups(localGroups, [cloudItem]); + expect(new Date(result[0].id).getTime()).toBeGreaterThan(new Date(result[1].id).getTime()); + }); +}); diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts index fc28414c5..d24513bc4 100644 --- a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts +++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts @@ -1,8 +1,10 @@ import * as MediaLibrary from 'expo-media-library'; +import { isVideoExtension } from 'src/services/drive/file/utils/exifHelpers'; +import { CloudAssetEntry } from 'src/services/photos/database/photosLocalDB'; +import { PhotoSyncStatus } from 'src/store/slices/photos'; import { GroupSyncStatus } from '../components/GroupHeader/PhotosGroupHeader'; import { TimelineDateGroup } from '../components/PhotosTimeline'; -import { PhotoBackupState, PhotoDateGroup, PhotoItem } from '../types'; -import { PhotoSyncStatus } from 'src/store/slices/photos'; +import { CloudPhotoItem, PhotoBackupState, PhotoDateGroup, PhotoItem, TimelinePhotoItem } from '../types'; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -43,6 +45,7 @@ export const assetToPhotoItem = ( const isVideo = asset.mediaType === MediaLibrary.MediaType.video; return { id: asset.id, + type: 'local', uri: asset.uri, backupState, mediaType: isVideo ? 'video' : 'photo', @@ -88,14 +91,49 @@ export const getGroupSyncStatus = ( return { type: 'scanning' }; case 'uploading': return { type: 'uploading', count: remainingCount, backupProgress }; + case 'fetching-cloud': + return { type: 'fetching' }; default: return { type: 'count', count: group.photos.length }; } }; +export const cloudEntryToPhotoItem = (entry: CloudAssetEntry): CloudPhotoItem => ({ + id: entry.remoteFileId, + type: 'cloud-only', + mediaType: isVideoExtension(entry.fileName.split('.').pop() ?? '') ? 'video' : 'photo', + thumbnailPath: entry.thumbnailPath, + thumbnailBucketId: entry.thumbnailBucketId, + thumbnailBucketFile: entry.thumbnailBucketFile, + thumbnailType: entry.thumbnailType, + deviceId: entry.deviceId, + createdAt: entry.createdAt, + fileName: entry.fileName, +}); + +export const mergeCloudIntoGroups = (localGroups: PhotoDateGroup[], cloudItems: CloudPhotoItem[]): PhotoDateGroup[] => { + if (cloudItems.length === 0) return localGroups; + + const now = new Date(); + const groupMap = new Map(localGroups.map((g) => [g.id, { ...g, photos: [...g.photos] }])); + + for (const item of cloudItems) { + const date = new Date(item.createdAt); + const key = date.toDateString(); + let group = groupMap.get(key); + if (!group) { + group = { id: key, label: getDateLabel(date, now), photos: [] }; + groupMap.set(key, group); + } + group.photos.push(item); + } + + return Array.from(groupMap.values()).sort((a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()); +}; + export type FlatItem = | { type: 'header'; id: string; label: string; syncStatus: GroupSyncStatus; count: number; isFirst: boolean } - | { type: 'photo'; photo: PhotoItem }; + | { type: 'photo'; photo: TimelinePhotoItem }; export const buildTimelineItems = (groups: TimelineDateGroup[]): { items: FlatItem[]; headerIndices: number[] } => { const items: FlatItem[] = []; diff --git a/src/services/photos/PhotoBackupFolders.ts b/src/services/photos/PhotoBackupFolders.ts index 89ee7d34a..cd3d473e6 100644 --- a/src/services/photos/PhotoBackupFolders.ts +++ b/src/services/photos/PhotoBackupFolders.ts @@ -31,6 +31,14 @@ class PhotoBackupFolderService { return dayUuid; } + async getRootFolderUuid(): Promise { + try { + return await this.getOrCreatePhotosRoot(); + } catch { + return null; + } + } + clearCache(): void { this.photosRootUuid = null; this.deviceFolderUuid.clear(); diff --git a/src/services/photos/PhotoCloudBrowser.spec.ts b/src/services/photos/PhotoCloudBrowser.spec.ts new file mode 100644 index 000000000..178286382 --- /dev/null +++ b/src/services/photos/PhotoCloudBrowser.spec.ts @@ -0,0 +1,195 @@ +import { driveFolderService } from 'src/services/drive/folder/driveFolder.service'; +import { photosLocalDB } from './database/photosLocalDB'; +import { photoBackupFolders } from './PhotoBackupFolders'; +import { photoCloudBrowser } from './PhotoCloudBrowser'; + +jest.mock('src/services/drive/folder/driveFolder.service', () => ({ + driveFolderService: { + getFolderFolders: jest.fn(), + getFolderContentByUuid: jest.fn(), + }, +})); + +jest.mock('./PhotoBackupFolders', () => ({ + photoBackupFolders: { + getRootFolderUuid: jest.fn(), + }, +})); + +jest.mock('./database/photosLocalDB', () => ({ + photosLocalDB: { + getCloudFetchCacheAge: jest.fn(), + upsertCloudAsset: jest.fn(), + }, +})); + +const mockFolderService = driveFolderService as jest.Mocked; +const mockBackupFolders = photoBackupFolders as jest.Mocked; +const mockPhotosLocalDB = photosLocalDB as jest.Mocked; + +const makeFolder = (uuid: string, plainName: string) => ({ uuid, plainName, name: plainName } as never); +const makeFile = (uuid: string, plainName: string) => ({ + uuid, + plainName, + name: plainName, + size: 1024, + thumbnails: [{ bucket_id: 'bucket-1', bucket_file: 'file-1', type: 'jpg' }], +} as never); + +beforeEach(() => { + jest.clearAllMocks(); + mockPhotosLocalDB.upsertCloudAsset.mockResolvedValue(undefined); +}); + +describe('PhotoCloudBrowser.listDeviceFolders', () => { + test('when the root folder does not exist, then an empty list is returned without calling the drive API', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce(null); + + const result = await photoCloudBrowser.listDeviceFolders(); + + expect(result).toEqual([]); + expect(mockFolderService.getFolderFolders).not.toHaveBeenCalled(); + }); + + test('when the root folder has two device subfolders, then both devices are returned', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + mockFolderService.getFolderFolders.mockResolvedValueOnce({ + folders: [makeFolder('d1-uuid', 'device-1'), makeFolder('d2-uuid', 'device-2')], + } as never); + + const result = await photoCloudBrowser.listDeviceFolders(); + + expect(result).toEqual([ + { uuid: 'd1-uuid', name: 'device-1' }, + { uuid: 'd2-uuid', name: 'device-2' }, + ]); + }); + + test('when the root has exactly 50 device folders, then a second page is fetched to check for more', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const firstBatch = Array.from({ length: 50 }, (_, i) => makeFolder(`d${i}`, `device-${i}`)); + const secondBatch: never[] = []; + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: firstBatch } as never) + .mockResolvedValueOnce({ folders: secondBatch } as never); + + const result = await photoCloudBrowser.listDeviceFolders(); + + expect(mockFolderService.getFolderFolders).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(50); + }); +}); + +describe('PhotoCloudBrowser.fetchMonth', () => { + test('when the cache for the given month is still fresh, then no drive API calls are made', async () => { + const freshTimestamp = Date.now() - 1000; + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(freshTimestamp); + + await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + + expect(mockFolderService.getFolderFolders).not.toHaveBeenCalled(); + expect(mockPhotosLocalDB.upsertCloudAsset).not.toHaveBeenCalled(); + }); + + test('when the cache is older than 24 hours, then the drive folder tree is traversed and assets are upserted', async () => { + const staleTimestamp = Date.now() - 25 * 60 * 60 * 1000; + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(staleTimestamp); + + const yearFolder = makeFolder('year-uuid', '2024'); + const monthFolder = makeFolder('month-uuid', '06'); + const dayFolder = makeFolder('day-uuid', '15'); + const file = makeFile('file-uuid', 'IMG_20240615_120000.jpg'); + + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [yearFolder] } as never) + .mockResolvedValueOnce({ folders: [monthFolder] } as never) + .mockResolvedValueOnce({ folders: [dayFolder] } as never); + + mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never); + + await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + + expect(mockPhotosLocalDB.upsertCloudAsset).toHaveBeenCalledTimes(1); + expect(mockPhotosLocalDB.upsertCloudAsset).toHaveBeenCalledWith( + expect.objectContaining({ + remoteFileId: 'file-uuid', + deviceId: 'device-1', + fileName: 'IMG_20240615_120000.jpg', + thumbnailBucketId: 'bucket-1', + thumbnailBucketFile: 'file-1', + thumbnailType: 'jpg', + }), + ); + }); + + test('when there is no cache entry for the month, then the drive folder tree is traversed', async () => { + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(null); + + mockFolderService.getFolderFolders.mockResolvedValue({ folders: [] } as never); + + await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + + expect(mockFolderService.getFolderFolders).toHaveBeenCalled(); + }); + + test('when the year folder does not exist in drive, then no assets are upserted', async () => { + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(null); + mockFolderService.getFolderFolders.mockResolvedValueOnce({ folders: [] } as never); + + await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + + expect(mockPhotosLocalDB.upsertCloudAsset).not.toHaveBeenCalled(); + }); + + test('when a file is inside a day folder, then the created at timestamp is derived from the folder hierarchy', async () => { + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(null); + + const yearFolder = makeFolder('year-uuid', '2024'); + const monthFolder = makeFolder('month-uuid', '06'); + const dayFolder = makeFolder('day-uuid', '15'); + const file = makeFile('file-uuid', 'photo.jpg'); + + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [yearFolder] } as never) + .mockResolvedValueOnce({ folders: [monthFolder] } as never) + .mockResolvedValueOnce({ folders: [dayFolder] } as never); + + mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never); + + await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + + const upsertCall = mockPhotosLocalDB.upsertCloudAsset.mock.calls[0][0]; + expect(upsertCall.createdAt).toBe(new Date(2024, 5, 15).getTime()); + }); +}); + +describe('PhotoCloudBrowser.syncAllDevicesFromMonth', () => { + test('when given two devices and three months back, then each device is fetched for each month', async () => { + const fetchMonthSpy = jest.spyOn(photoCloudBrowser, 'fetchMonth').mockResolvedValue(undefined); + + const devices = [ + { uuid: 'd1-uuid', name: 'device-1' }, + { uuid: 'd2-uuid', name: 'device-2' }, + ]; + + await photoCloudBrowser.syncAllDevicesFromMonth(devices, 2024, 6, 3); + + expect(fetchMonthSpy).toHaveBeenCalledTimes(6); + fetchMonthSpy.mockRestore(); + }); + + test('when the month range crosses a year boundary, then the year is decremented correctly', async () => { + const fetchMonthSpy = jest.spyOn(photoCloudBrowser, 'fetchMonth').mockResolvedValue(undefined); + + const devices = [{ uuid: 'd1-uuid', name: 'device-1' }]; + + await photoCloudBrowser.syncAllDevicesFromMonth(devices, 2024, 2, 3); + + const calls = fetchMonthSpy.mock.calls; + expect(calls).toContainEqual(['device-1', 'd1-uuid', 2024, 2]); + expect(calls).toContainEqual(['device-1', 'd1-uuid', 2024, 1]); + expect(calls).toContainEqual(['device-1', 'd1-uuid', 2023, 12]); + + fetchMonthSpy.mockRestore(); + }); +}); diff --git a/src/services/photos/PhotoCloudBrowser.ts b/src/services/photos/PhotoCloudBrowser.ts new file mode 100644 index 000000000..6c9557060 --- /dev/null +++ b/src/services/photos/PhotoCloudBrowser.ts @@ -0,0 +1,119 @@ +import { DriveFileData } from '@internxt-mobile/types/drive/file'; +import { FetchPaginatedFolder } from '@internxt/sdk/dist/drive/storage/types'; +import { driveFolderService } from 'src/services/drive/folder/driveFolder.service'; +import { photosLocalDB } from './database/photosLocalDB'; +import { photoBackupFolders } from './PhotoBackupFolders'; + +const HOUR_MS = 60 * 60 * 1000; +// TODO: REMEMBER TO CHANGE THE TIME BEFORE PR +const CACHE_TTL_MS = 1 * HOUR_MS; +const PAGE_SIZE = 50; + +const fetchAllPages = async (fetcher: (offset: number) => Promise): Promise => { + const all: T[] = []; + let offset = 0; + let batch: T[]; + do { + batch = await fetcher(offset); + all.push(...batch); + offset += PAGE_SIZE; + } while (batch.length === PAGE_SIZE); + return all; +}; + +class PhotoCloudBrowserService { + constructor( + private backupFolders: typeof photoBackupFolders, + private folderService: typeof driveFolderService, + private localDB: typeof photosLocalDB, + ) {} + + async listDeviceFolders(): Promise<{ uuid: string; name: string }[]> { + const rootUuid = await this.backupFolders.getRootFolderUuid(); + if (!rootUuid) return []; + + const folders = await fetchAllPages((offset) => + this.folderService.getFolderFolders(rootUuid, offset, PAGE_SIZE).then((r) => r.folders), + ); + return folders.map((f) => ({ uuid: f.uuid, name: f.plainName ?? '' })); + } + + async fetchMonth(deviceId: string, deviceFolderUuid: string, year: number, month: number): Promise { + const cacheAge = await this.localDB.getCloudFetchCacheAge(deviceId, year, month); + if (cacheAge !== null && Date.now() - cacheAge < CACHE_TTL_MS) return; + + const yearFolder = await this.findChildFolder(deviceFolderUuid, String(year)); + if (!yearFolder) return; + + const monthStr = String(month).padStart(2, '0'); + const monthFolder = await this.findChildFolder(yearFolder.uuid, monthStr); + if (!monthFolder) return; + + const dayFolders = await this.listAllFolders(monthFolder.uuid); + const now = Date.now(); + + for (const dayFolder of dayFolders) { + const day = parseInt(dayFolder.plainName ?? '', 10); + const folderDate = new Date(year, month - 1, isNaN(day) ? 1 : day).getTime(); + + const files = await this.listFilesWithThumbnails(dayFolder.uuid); + for (const file of files) { + const baseName = file.plainName ?? file.name; + const fileName = file.type ? `${baseName}.${file.type}` : baseName; + const createdAt = folderDate; + const thumb = file.thumbnails?.[0] ?? null; + await this.localDB.upsertCloudAsset({ + remoteFileId: file.uuid, + deviceId, + createdAt, + fileName, + fileSize: file.size ? Number(file.size) : null, + thumbnailPath: null, + thumbnailBucketId: thumb?.bucket_id ?? null, + thumbnailBucketFile: thumb?.bucket_file ?? null, + thumbnailType: thumb?.type ?? null, + discoveredAt: now, + }); + } + } + } + + async syncAllDevicesFromMonth( + devices: { uuid: string; name: string }[], + fromYear: number, + fromMonth: number, + monthsBack = 12, + ): Promise { + for (let i = 0; i < monthsBack; i++) { + let year = fromYear; + let month = fromMonth - i; + while (month <= 0) { + month += 12; + year -= 1; + } + for (const device of devices) { + await this.fetchMonth(device.name, device.uuid, year, month); + } + } + } + + private async findChildFolder(parentUuid: string, name: string): Promise { + const folders = await fetchAllPages((offset) => + this.folderService.getFolderFolders(parentUuid, offset, PAGE_SIZE).then((r) => r.folders), + ); + return folders.find((f) => (f.plainName ?? '') === name) ?? null; + } + + private async listAllFolders(parentUuid: string): Promise { + return fetchAllPages((offset) => + this.folderService.getFolderFolders(parentUuid, offset, PAGE_SIZE).then((r) => r.folders), + ); + } + + private async listFilesWithThumbnails(folderUuid: string): Promise { + const content = await this.folderService.getFolderContentByUuid(folderUuid); + return content.files; + } +} + +export const photoCloudBrowser = new PhotoCloudBrowserService(photoBackupFolders, driveFolderService, photosLocalDB); diff --git a/src/services/photos/PhotoUploadQueue.ts b/src/services/photos/PhotoUploadQueue.ts index d852568a9..27e0a34d8 100644 --- a/src/services/photos/PhotoUploadQueue.ts +++ b/src/services/photos/PhotoUploadQueue.ts @@ -12,16 +12,12 @@ export interface AssetUploadJob { interface UploadQueueCallbacks { onAssetStart?: (assetId: string) => void; onAssetProgress?: (assetId: string, ratio: number) => void; - onAssetDone?: (assetId: string, remoteFileId: string, modificationTime: number) => void; - onAssetError?: (assetId: string, error: Error) => void; + onAssetDone?: (assetId: string, remoteFileId: string, modificationTime: number) => Promise | void; + onAssetError?: (assetId: string, error: Error) => Promise | void; } export const PhotoUploadQueue = { - async start( - jobs: AssetUploadJob[], - deviceId: string, - callbacks: UploadQueueCallbacks, - ): Promise { + async start(jobs: AssetUploadJob[], deviceId: string, callbacks: UploadQueueCallbacks): Promise { const limit = pLimit(UPLOAD_CONCURRENCY); await Promise.all( @@ -37,9 +33,9 @@ export const PhotoUploadQueue = { : await PhotoUploadService.upload(asset, deviceId, (ratio) => callbacks.onAssetProgress?.(asset.id, ratio), ); - callbacks.onAssetDone?.(asset.id, remoteFileId, asset.modificationTime); + await callbacks.onAssetDone?.(asset.id, remoteFileId, asset.modificationTime); } catch (uploadError) { - callbacks.onAssetError?.(asset.id, uploadError as Error); + await callbacks.onAssetError?.(asset.id, uploadError as Error); } }), ), diff --git a/src/services/photos/database/photosLocalDB.spec.ts b/src/services/photos/database/photosLocalDB.spec.ts index 813f04f0c..34ed000f3 100644 --- a/src/services/photos/database/photosLocalDB.spec.ts +++ b/src/services/photos/database/photosLocalDB.spec.ts @@ -20,11 +20,11 @@ describe('photosLocalDB', () => { (photosLocalDB as any).initPromise = null; }); - test('when the database is initialized, then it opens the database file and creates the table and index', async () => { + test('when the database is initialized, then it opens the database file and creates all tables and indexes', async () => { await photosLocalDB.init(); expect(mockSqlite.open).toHaveBeenCalledWith('photos_sync.db'); - expect(mockSqlite.executeSql).toHaveBeenCalledTimes(2); + expect(mockSqlite.executeSql).toHaveBeenCalledTimes(6); }); test('when the database is initialized a second time, then no database calls are made', async () => { @@ -249,4 +249,156 @@ describe('photosLocalDB', () => { expect(mockSqlite.executeSql).toHaveBeenCalledWith('photos_sync.db', expect.stringContaining('DELETE FROM')); }); + + test('when the database is initialized, then the cloud asset table and its three indexes are also created', async () => { + await photosLocalDB.init(); + + const statements = mockSqlite.executeSql.mock.calls.map(([, stmt]) => stmt as string); + const createTableCalls = statements.filter((s) => s.includes('CREATE TABLE')); + const createIndexCalls = statements.filter((s) => s.includes('CREATE INDEX')); + expect(createTableCalls).toHaveLength(2); + expect(createIndexCalls).toHaveLength(4); + }); +}); + +describe('photosLocalDB cloud asset methods', () => { + beforeEach(() => { + jest.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (photosLocalDB as any).initPromise = null; + }); + + test('when a cloud asset is upserted, then all its fields are passed to the database', async () => { + await photosLocalDB.upsertCloudAsset({ + remoteFileId: 'remote-1', + deviceId: 'device-1', + createdAt: 1718000000000, + fileName: 'photo.jpg', + fileSize: 2048, + thumbnailPath: null, + thumbnailBucketId: 'bucket-1', + thumbnailBucketFile: 'file-1', + thumbnailType: 'jpg', + discoveredAt: 1718100000000, + }); + + expect(mockSqlite.executeSql).toHaveBeenCalledTimes(1); + const [, , params] = mockSqlite.executeSql.mock.calls[0]; + expect(params).toEqual([ + 'remote-1', + 'device-1', + 1718000000000, + 'photo.jpg', + 2048, + null, + 'bucket-1', + 'file-1', + 'jpg', + 1718100000000, + ]); + }); + + test('when all cloud assets are fetched, then each database row is mapped to a typed entry', async () => { + mockSqlite.getAllAsync.mockResolvedValueOnce([ + { + remote_file_id: 'r1', + device_id: 'd1', + created_at: 1718000000, + file_name: 'a.jpg', + file_size: 512, + thumbnail_path: '/local/thumb.jpg', + thumbnail_bucket_id: 'b1', + thumbnail_bucket_file: 'f1', + thumbnail_type: 'jpg', + discovered_at: 1718100000, + }, + ]); + + const result = await photosLocalDB.getAllCloudAssets(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + remoteFileId: 'r1', + deviceId: 'd1', + createdAt: 1718000000, + fileName: 'a.jpg', + fileSize: 512, + thumbnailPath: '/local/thumb.jpg', + thumbnailBucketId: 'b1', + thumbnailBucketFile: 'f1', + thumbnailType: 'jpg', + discoveredAt: 1718100000, + }); + }); + + test('when cloud assets are fetched by range, then the from and to timestamps are passed as parameters', async () => { + mockSqlite.getAllAsync.mockResolvedValueOnce([]); + + await photosLocalDB.getCloudAssetsByRange(1000, 2000); + + const [, , params] = mockSqlite.getAllAsync.mock.calls[0]; + expect(params).toEqual([1000, 2000]); + }); + + test('when a cloud thumbnail path is set, then the path and remote file id are passed to the database', async () => { + await photosLocalDB.setCloudThumbnailPath('remote-1', '/path/to/thumb.jpg'); + + expect(mockSqlite.executeSql).toHaveBeenCalledTimes(1); + const [, , params] = mockSqlite.executeSql.mock.calls[0]; + expect(params).toEqual(['/path/to/thumb.jpg', 'remote-1']); + }); + + test('when a cloud asset is deleted, then its remote file id is passed to the database', async () => { + await photosLocalDB.deleteCloudAsset('remote-1'); + + expect(mockSqlite.executeSql).toHaveBeenCalledTimes(1); + const [, , params] = mockSqlite.executeSql.mock.calls[0]; + expect(params).toEqual(['remote-1']); + }); + + test('when there are no cloud entries for a given month, then the cache age is null', async () => { + mockSqlite.getFirstAsync.mockResolvedValueOnce({ latest: null }); + + const result = await photosLocalDB.getCloudFetchCacheAge('device-1', 2024, 6); + + expect(result).toBeNull(); + }); + + test('when cloud entries exist for a given month, then the most recent discovered at timestamp is returned', async () => { + mockSqlite.getFirstAsync.mockResolvedValueOnce({ latest: 1718100000000 }); + + const result = await photosLocalDB.getCloudFetchCacheAge('device-1', 2024, 6); + + expect(result).toBe(1718100000000); + }); + + test('when fetching cache age for a month, then the correct month boundaries are passed as timestamps', async () => { + mockSqlite.getFirstAsync.mockResolvedValueOnce({ latest: null }); + + await photosLocalDB.getCloudFetchCacheAge('device-1', 2024, 6); + + const [, , params] = mockSqlite.getFirstAsync.mock.calls[0]; + const expectedFrom = new Date(2024, 5, 1).getTime(); + const expectedTo = new Date(2024, 6, 1).getTime(); + expect(params).toEqual(['device-1', expectedFrom, expectedTo]); + }); + + test('when synced remote file ids are fetched, then the result is a set of all returned ids', async () => { + mockSqlite.getAllAsync.mockResolvedValueOnce([ + { remote_file_id: 'r1' }, + { remote_file_id: 'r2' }, + ]); + + const result = await photosLocalDB.getSyncedRemoteFileIds(); + + expect(result).toEqual(new Set(['r1', 'r2'])); + }); + + test('when there are no synced remote file ids, then an empty set is returned', async () => { + mockSqlite.getAllAsync.mockResolvedValueOnce([]); + + const result = await photosLocalDB.getSyncedRemoteFileIds(); + + expect(result).toEqual(new Set()); + }); }); diff --git a/src/services/photos/database/photosLocalDB.ts b/src/services/photos/database/photosLocalDB.ts index 7d579ada2..ca2dcae79 100644 --- a/src/services/photos/database/photosLocalDB.ts +++ b/src/services/photos/database/photosLocalDB.ts @@ -1,8 +1,35 @@ import sqliteService from '../../SqliteService'; import assetSyncTable from './tables/asset_sync'; +import cloudAssetTable from './tables/cloud_asset'; const DB_NAME = 'photos_sync.db'; +export interface CloudAssetEntry { + remoteFileId: string; + deviceId: string; + createdAt: number; + fileName: string; + fileSize: number | null; + thumbnailPath: string | null; + thumbnailBucketId: string | null; + thumbnailBucketFile: string | null; + thumbnailType: string | null; + discoveredAt: number; +} + +interface CloudAssetRow { + remote_file_id: string; + device_id: string; + created_at: number; + file_name: string; + file_size: number | null; + thumbnail_path: string | null; + thumbnail_bucket_id: string | null; + thumbnail_bucket_file: string | null; + thumbnail_type: string | null; + discovered_at: number; +} + export type AssetSyncStatus = 'pending' | 'pending_edit' | 'synced' | 'error'; export interface AssetSyncEntry { @@ -35,6 +62,21 @@ export interface SyncedAssetInfo { const CHUNK_SIZE = 300; +const rowToCloudAssetEntry = (row: CloudAssetRow): CloudAssetEntry => { + return { + remoteFileId: row.remote_file_id, + deviceId: row.device_id, + createdAt: row.created_at, + fileName: row.file_name, + fileSize: row.file_size, + thumbnailPath: row.thumbnail_path, + thumbnailBucketId: row.thumbnail_bucket_id, + thumbnailBucketFile: row.thumbnail_bucket_file, + thumbnailType: row.thumbnail_type, + discoveredAt: row.discovered_at, + }; +}; + class PhotosLocalDB { private initPromise: Promise | null = null; @@ -43,6 +85,10 @@ class PhotosLocalDB { await sqliteService.open(DB_NAME); await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.createTable); await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.createIndex); + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.createTable); + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.createIndexCreated); + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.createIndexDevice); + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.createIndexMonth); })(); return this.initPromise; } @@ -129,6 +175,67 @@ class PhotosLocalDB { async reset(): Promise { await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.reset); } + + async getSyncedRemoteFileIds(): Promise> { + const rows = await sqliteService.getAllAsync<{ remote_file_id: string }>( + DB_NAME, + assetSyncTable.statements.getSyncedRemoteFileIds, + ); + return new Set(rows.map((r) => r.remote_file_id)); + } + + // --- cloud_asset methods --- + + async upsertCloudAsset(entry: CloudAssetEntry): Promise { + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.upsert, [ + entry.remoteFileId, + entry.deviceId, + entry.createdAt, + entry.fileName, + entry.fileSize ?? null, + entry.thumbnailPath ?? null, + entry.thumbnailBucketId ?? null, + entry.thumbnailBucketFile ?? null, + entry.thumbnailType ?? null, + entry.discoveredAt, + ]); + } + + async getAllCloudAssets(): Promise { + const rows = await sqliteService.getAllAsync(DB_NAME, cloudAssetTable.statements.getAll); + return rows.map(rowToCloudAssetEntry); + } + + async getCloudAssetsByRange(from: number, to: number): Promise { + const rows = await sqliteService.getAllAsync(DB_NAME, cloudAssetTable.statements.getByRange, [ + from, + to, + ]); + return rows.map(rowToCloudAssetEntry); + } + + async setCloudThumbnailPath(remoteFileId: string, path: string | null): Promise { + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.setThumbnailPath, [path, remoteFileId]); + } + + async deleteCloudAsset(remoteFileId: string): Promise { + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.delete, [remoteFileId]); + } + + async resetCloudAssets(): Promise { + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.reset); + } + + async getCloudFetchCacheAge(deviceId: string, year: number, month: number): Promise { + const from = new Date(year, month - 1, 1).getTime(); + const to = new Date(year, month, 1).getTime(); + const row = await sqliteService.getFirstAsync<{ latest: number | null }>( + DB_NAME, + cloudAssetTable.statements.getLatestDiscoveredAt, + [deviceId, from, to], + ); + return row?.latest ?? null; + } } export const photosLocalDB = new PhotosLocalDB(); diff --git a/src/services/photos/database/tables/asset_sync.ts b/src/services/photos/database/tables/asset_sync.ts index 2e259e4eb..a6d864459 100644 --- a/src/services/photos/database/tables/asset_sync.ts +++ b/src/services/photos/database/tables/asset_sync.ts @@ -61,6 +61,7 @@ const statements = { `SELECT asset_id, modification_time FROM ${TABLE_NAME} WHERE asset_id IN (${placeholders}) AND status = 'synced';`, getPendingAssets: `SELECT asset_id, status, remote_file_id FROM ${TABLE_NAME} WHERE status != 'synced';`, reset: `DELETE FROM ${TABLE_NAME};`, + getSyncedRemoteFileIds: `SELECT remote_file_id FROM ${TABLE_NAME} WHERE status = 'synced' AND remote_file_id IS NOT NULL;`, }; export default { TABLE_NAME, statements }; diff --git a/src/services/photos/database/tables/cloud_asset.ts b/src/services/photos/database/tables/cloud_asset.ts new file mode 100644 index 000000000..197e5eed1 --- /dev/null +++ b/src/services/photos/database/tables/cloud_asset.ts @@ -0,0 +1,70 @@ +const TABLE_NAME = 'cloud_asset'; + +const statements = { + createTable: ` + CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( + remote_file_id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER, + thumbnail_path TEXT, + thumbnail_bucket_id TEXT, + thumbnail_bucket_file TEXT, + thumbnail_type TEXT, + discovered_at INTEGER NOT NULL + ); + `, + createIndexCreated: `CREATE INDEX IF NOT EXISTS idx_cloud_asset_created ON ${TABLE_NAME}(created_at DESC);`, + createIndexDevice: `CREATE INDEX IF NOT EXISTS idx_cloud_asset_device ON ${TABLE_NAME}(device_id);`, + createIndexMonth: `CREATE INDEX IF NOT EXISTS idx_cloud_asset_month ON ${TABLE_NAME}(device_id, created_at);`, + + upsert: ` + INSERT INTO ${TABLE_NAME} ( + remote_file_id, device_id, created_at, file_name, file_size, + thumbnail_path, thumbnail_bucket_id, thumbnail_bucket_file, thumbnail_type, discovered_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(remote_file_id) DO UPDATE SET + device_id = excluded.device_id, + created_at = excluded.created_at, + file_name = excluded.file_name, + file_size = excluded.file_size, + thumbnail_path = COALESCE(${TABLE_NAME}.thumbnail_path, excluded.thumbnail_path), + thumbnail_bucket_id = excluded.thumbnail_bucket_id, + thumbnail_bucket_file = excluded.thumbnail_bucket_file, + thumbnail_type = excluded.thumbnail_type, + discovered_at = excluded.discovered_at; + `, + + getAll: ` + SELECT remote_file_id, device_id, created_at, file_name, file_size, + thumbnail_path, thumbnail_bucket_id, thumbnail_bucket_file, thumbnail_type, discovered_at + FROM ${TABLE_NAME} + ORDER BY created_at DESC; + `, + + getByRange: ` + SELECT remote_file_id, device_id, created_at, file_name, file_size, + thumbnail_path, thumbnail_bucket_id, thumbnail_bucket_file, thumbnail_type, discovered_at + FROM ${TABLE_NAME} + WHERE created_at >= ? AND created_at <= ? + ORDER BY created_at DESC; + `, + + setThumbnailPath: ` + UPDATE ${TABLE_NAME} SET thumbnail_path = ? WHERE remote_file_id = ?; + `, + + delete: `DELETE FROM ${TABLE_NAME} WHERE remote_file_id = ?;`, + reset: `DELETE FROM ${TABLE_NAME};`, + + getLatestDiscoveredAt: ` + SELECT MAX(discovered_at) AS latest + FROM ${TABLE_NAME} + WHERE device_id = ? + AND created_at >= ? + AND created_at < ?; + `, +}; + +export default { TABLE_NAME, statements }; diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 72603a2b1..f987d0414 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -14,6 +14,7 @@ import notificationsService from '../../../services/NotificationsService'; import { default as userService } from '../../../services/UserService'; import { AsyncStorageKey, NotificationType } from '../../../types'; import { driveActions } from '../drive'; +import { signOutThunk as photosSignOutThunk } from '../photos'; import { uiActions } from '../ui'; export interface AuthState { loggedIn: boolean | null; @@ -208,6 +209,7 @@ export const signOutThunk = createAsyncThunk< dispatch(uiActions.resetState()); dispatch(authActions.resetState()); dispatch(driveActions.resetState()); + dispatch(photosSignOutThunk()); dispatch(authActions.setLoggedIn(false)); authService.emitLogoutEvent(); }); diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts index 916bb41e7..c1b426464 100644 --- a/src/store/slices/photos/index.spec.ts +++ b/src/store/slices/photos/index.spec.ts @@ -2,6 +2,7 @@ import { photoPermissionService } from '@internxt-mobile/services/photos/photoPe import { configureStore } from '@reduxjs/toolkit'; import asyncStorageService from 'src/services/AsyncStorageService'; import { PhotoAssetScanner } from 'src/services/photos/PhotoAssetScanner'; +import { photoCloudBrowser } from 'src/services/photos/PhotoCloudBrowser'; import { PhotoDeduplicator } from 'src/services/photos/PhotoDeduplicator'; import { PhotoDeviceId } from 'src/services/photos/PhotoDeviceId'; import { PhotoUploadQueue } from 'src/services/photos/PhotoUploadQueue'; @@ -16,6 +17,7 @@ import photosReducer, { photosSlice, PhotosState, runBackupCycleThunk, + runCloudMetadataSyncThunk, runDiscoveryThunk, setNetworkConditionThunk, } from './index'; @@ -55,6 +57,13 @@ jest.mock('src/services/photos/PhotoDeduplicator', () => ({ PhotoDeduplicator: { getAssetsToSync: jest.fn().mockResolvedValue({ newAssets: [], editedAssets: [] }) }, })); +jest.mock('src/services/photos/PhotoCloudBrowser', () => ({ + photoCloudBrowser: { + listDeviceFolders: jest.fn().mockResolvedValue([]), + syncAllDevicesFromMonth: jest.fn().mockResolvedValue(undefined), + }, +})); + jest.mock('src/services/photos/database/photosLocalDB', () => ({ photosLocalDB: { init: jest.fn().mockResolvedValue(undefined), @@ -67,6 +76,7 @@ jest.mock('src/services/photos/database/photosLocalDB', () => ({ })); const mockAsyncStorage = asyncStorageService as jest.Mocked; +const mockCloudBrowser = photoCloudBrowser as jest.Mocked; const mockPermissionService = photoPermissionService as jest.Mocked; const mockPhotoDeviceId = PhotoDeviceId as jest.Mocked; const mockScanner = PhotoAssetScanner as jest.Mocked; @@ -103,6 +113,8 @@ describe('photos slice', () => { mockPhotosLocalDB.markPendingEdit.mockResolvedValue(undefined); mockPhotosLocalDB.markSynced.mockResolvedValue(undefined); mockPhotosLocalDB.markError.mockResolvedValue(undefined); + mockCloudBrowser.listDeviceFolders.mockResolvedValue([]); + mockCloudBrowser.syncAllDevicesFromMonth.mockResolvedValue(undefined); // Prevent checkPermissionRevocationThunk from overwriting permissionStatus with undefined mockPermissionService.getStatus.mockResolvedValue('granted'); }); @@ -401,4 +413,57 @@ describe('photos slice', () => { expect(store.getState().photos.pendingBackupAssets).toBe(5); expect(store.getState().photos.syncStatus).toBe('synced'); }); + + describe('runCloudMetadataSyncThunk', () => { + test('when backup is disabled, then the drive API is not called', async () => { + const store = makeStore(); + + await store.dispatch(runCloudMetadataSyncThunk()); + + expect(mockCloudBrowser.listDeviceFolders).not.toHaveBeenCalled(); + }); + + test('when backup is enabled but the device id is not set, then the drive API is not called', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, deviceId: null })); + + await store.dispatch(runCloudMetadataSyncThunk()); + + expect(mockCloudBrowser.listDeviceFolders).not.toHaveBeenCalled(); + }); + + test('when there are no device folders in drive, then syncing is skipped', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, deviceId: 'device-1' })); + + await store.dispatch(runCloudMetadataSyncThunk()); + + expect(mockCloudBrowser.syncAllDevicesFromMonth).not.toHaveBeenCalled(); + }); + + test('when device folders exist, then all devices are synced for the last 12 months', async () => { + const devices = [{ uuid: 'd1-uuid', name: 'device-1' }]; + mockCloudBrowser.listDeviceFolders.mockResolvedValueOnce(devices); + + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, deviceId: 'device-1' })); + + await store.dispatch(runCloudMetadataSyncThunk()); + + expect(mockCloudBrowser.syncAllDevicesFromMonth).toHaveBeenCalledWith(devices, expect.any(Number), expect.any(Number), 12); + }); + + test('when the cloud sync step fails during a backup cycle, then the cycle still completes without throwing', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); + mockPermissionService.getStatus.mockResolvedValueOnce('granted'); + mockPhotoDeviceId.getOrCreate.mockResolvedValueOnce('device-id'); + mockScanner.scanAll.mockResolvedValueOnce([] as never); + mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: [], editedAssets: [] } as never); + mockPhotosLocalDB.init.mockResolvedValueOnce(undefined); + mockCloudBrowser.listDeviceFolders.mockRejectedValueOnce(new Error('network error')); + + await expect(store.dispatch(runBackupCycleThunk())).resolves.not.toThrow(); + }); + }); }); diff --git a/src/store/slices/photos/index.ts b/src/store/slices/photos/index.ts index db8dd1f9d..ea7dc7f6d 100644 --- a/src/store/slices/photos/index.ts +++ b/src/store/slices/photos/index.ts @@ -1,6 +1,8 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import asyncStorageService from 'src/services/AsyncStorageService'; +import errorService from 'src/services/ErrorService'; import { PhotoAssetScanner } from 'src/services/photos/PhotoAssetScanner'; +import { photoCloudBrowser } from 'src/services/photos/PhotoCloudBrowser'; import { PhotoDeduplicator } from 'src/services/photos/PhotoDeduplicator'; import { PhotoDeviceId } from 'src/services/photos/PhotoDeviceId'; import { PhotoUploadQueue } from 'src/services/photos/PhotoUploadQueue'; @@ -15,7 +17,7 @@ import { logger } from '../../../services/common'; import { RootState } from '../../index'; export type PhotoNetworkCondition = 'wifi-only' | 'wifi-and-data'; -export type PhotoSyncStatus = 'idle' | 'scanning' | 'uploading' | 'synced' | 'paused' | 'error'; +export type PhotoSyncStatus = 'idle' | 'scanning' | 'uploading' | 'fetching-cloud' | 'synced' | 'paused' | 'error'; export interface PhotosState { enabled: boolean; @@ -214,27 +216,62 @@ export const runUploadThunk = createAsyncThunk }); dispatch(photosSlice.actions.setSyncStatus('synced')); - dispatch(photosSlice.actions.setLastSyncTimestamp(Date.now())); dispatch(photosSlice.actions.setCurrentUploadProgress(0)); }, ); +export const runCloudMetadataSyncThunk = createAsyncThunk( + 'photos/runCloudMetadataSync', + async (_, { getState }) => { + const { enabled, deviceId } = getState().photos; + if (!enabled || !deviceId) return; + + await photosLocalDB.init(); + const devices = await photoCloudBrowser.listDeviceFolders(); + if (devices.length === 0) return; + + const now = new Date(); + await photoCloudBrowser.syncAllDevicesFromMonth(devices, now.getFullYear(), now.getMonth() + 1, 12); + }, +); + export const runBackupCycleThunk = createAsyncThunk( 'photos/runBackupCycle', async (_, { getState, dispatch }) => { const { syncStatus } = getState().photos; - if (syncStatus === 'scanning' || syncStatus === 'uploading') return; + if (syncStatus === 'scanning' || syncStatus === 'uploading' || syncStatus === 'fetching-cloud') { + return; + } await dispatch(checkPermissionRevocationThunk()); const { enabled, permissionStatus, deviceId } = getState().photos; - if (!enabled || !isPermissionActive(permissionStatus)) return; + if (!enabled || !isPermissionActive(permissionStatus)) { + return; + } + + if (!deviceId) { + await dispatch(initDeviceIdThunk()); + } + + const { syncStatus: statusBeforeCloud } = getState().photos; + if (statusBeforeCloud === 'scanning' || statusBeforeCloud === 'uploading') { + return; + } + + dispatch(photosSlice.actions.setSyncStatus('fetching-cloud')); + try { + await dispatch(runCloudMetadataSyncThunk()).unwrap(); + } catch (err) { + errorService.reportError(err); + } - if (!deviceId) await dispatch(initDeviceIdThunk()); await dispatch(runDiscoveryThunk()); if (getState().photos.pendingBackupAssets > 0) { await dispatch(runUploadThunk()); } + + dispatch(photosSlice.actions.setLastSyncTimestamp(Date.now())); }, ); @@ -304,4 +341,13 @@ export const photosSlice = createSlice({ }); export const photosActions = photosSlice.actions; + +export const signOutThunk = createAsyncThunk( + 'photos/signOut', + async (_, { dispatch }) => { + await Promise.all([photosLocalDB.reset(), photosLocalDB.resetCloudAssets()]).catch(errorService.reportError); + dispatch(photosActions.resetState()); + }, +); + export default photosSlice.reducer; From 09e8b72eb48ef3790702bd3def17799c4f4406a8 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 7 May 2026 09:33:08 +0200 Subject: [PATCH 2/4] Fixed sign out modal --- src/App.tsx | 7 ++++++- src/components/modals/SignOutModal/index.tsx | 11 ++++++----- src/navigation/TabExplorerNavigator.tsx | 2 -- src/navigation/index.tsx | 9 ++++++--- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3dfa27823..c1a1ec4a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,9 @@ import DeleteAccountModal from './components/modals/DeleteAccountModal'; import EditNameModal from './components/modals/EditNameModal'; import LanguageModal from './components/modals/LanguageModal'; import LinkCopiedModal from './components/modals/LinkCopiedModal'; +import SignOutModal from './components/modals/SignOutModal'; +import { useNavigationContainerRef } from '@react-navigation/native'; import { DriveContextProvider } from './contexts/Drive'; import { ThemeProvider, useTheme } from './contexts/Theme'; import { getRemoteUpdateIfAvailable, useLoadFonts } from './helpers'; @@ -35,10 +37,12 @@ import { authThunks } from './store/slices/auth'; import { paymentsThunks } from './store/slices/payments'; import { hydratePhotosStateThunk } from './store/slices/photos'; import { uiActions } from './store/slices/ui'; +import { RootStackParamList } from './types/navigation'; let listener: NativeEventSubscription | null = null; function AppContent(): JSX.Element { + const navigationRef = useNavigationContainerRef(); const dispatch = useAppDispatch(); const tailwind = useTailwind(); const getColor = useGetColor(); @@ -241,7 +245,7 @@ function AppContent(): JSX.Element { onScreenUnlocked={handleUnlockScreen} /> - {initialScreenLocked && screenLocked ? null : } + {initialScreenLocked && screenLocked ? null : } @@ -252,6 +256,7 @@ function AppContent(): JSX.Element { onClose={onChangeProfilePictureModalClosed} /> + navigationRef.reset({ index: 0, routes: [{ name: 'SignIn' }] })} /> ) : ( diff --git a/src/components/modals/SignOutModal/index.tsx b/src/components/modals/SignOutModal/index.tsx index 10574a1e6..855024dcf 100644 --- a/src/components/modals/SignOutModal/index.tsx +++ b/src/components/modals/SignOutModal/index.tsx @@ -1,23 +1,24 @@ import { View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; import { useTailwind } from 'tailwind-rn'; import strings from '../../../../assets/lang/strings'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { authSelectors, authThunks } from '../../../store/slices/auth'; import { uiActions } from '../../../store/slices/ui'; -import { RootScreenNavigationProp } from '../../../types/navigation'; import AppButton from '../../AppButton'; import AppText from '../../AppText'; import UserProfilePicture from '../../UserProfilePicture'; import CenterModal from '../CenterModal'; -function SignOutModal(): JSX.Element { +interface SignOutModalProps { + onSignedOut: () => void; +} + +function SignOutModal({ onSignedOut }: SignOutModalProps): JSX.Element { const tailwind = useTailwind(); const dispatch = useAppDispatch(); const userFullName = useAppSelector(authSelectors.userFullName); const user = useAppSelector((state) => state.auth.user); - const navigation = useNavigation>(); const { isSignOutModalOpen } = useAppSelector((state) => state.ui); const onClosed = () => { dispatch(uiActions.setIsSignOutModalOpen(false)); @@ -27,7 +28,7 @@ function SignOutModal(): JSX.Element { }; const onSignOutButtonPressed = () => { dispatch(authThunks.signOutThunk({ reason: 'manual' })); - navigation.replace('SignIn'); + onSignedOut(); onClosed(); }; diff --git a/src/navigation/TabExplorerNavigator.tsx b/src/navigation/TabExplorerNavigator.tsx index 6ac528d22..e1fd459fa 100644 --- a/src/navigation/TabExplorerNavigator.tsx +++ b/src/navigation/TabExplorerNavigator.tsx @@ -19,7 +19,6 @@ import MoveItemsModal from '../components/modals/MoveItemsModal'; import NotEnoughDeviceSpaceModal from '../components/modals/NotEnoughDeviceSpaceModal'; import RunOutOfStorageModal from '../components/modals/RunOutOfStorageModal'; import { SharedLinkInfoModal } from '../components/modals/SharedLinkInfoModal'; -import SignOutModal from '../components/modals/SignOutModal'; import useGetColor from '../hooks/useColor'; import { SharedScreen } from '../screens/drive/SharedScreen/SharedScreen'; import EmptyScreen from '../screens/EmptyScreen'; @@ -102,7 +101,6 @@ export default function TabExplorerNavigator(props: RootStackScreenProps<'TabExp - ); diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 7ef17c5ab..7396b3dbf 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -1,4 +1,4 @@ -import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'; +import { NavigationContainer, NavigationContainerRefWithCurrent } from '@react-navigation/native'; import { useRef } from 'react'; import { View } from 'react-native'; @@ -6,8 +6,11 @@ import { RootStackParamList } from '../types/navigation'; import LinkingConfiguration from './LinkingConfiguration'; import RootNavigator from './RootNavigator'; -export default function Navigation() { - const navigationRef = useNavigationContainerRef(); +interface NavigationProps { + navigationRef: NavigationContainerRefWithCurrent; +} + +export default function Navigation({ navigationRef }: NavigationProps) { const routeNameRef = useRef(); return ( From e0bb2f442267e3df28839575c85e641f7b6e1582 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 7 May 2026 11:13:08 +0200 Subject: [PATCH 3/4] Added cloudFetchRevision to PhotosState to update the cloud assets retrieved whenever a full month is retrieved --- .../useDiscoverPhotosSheet.spec.tsx | 24 +++++-- .../PhotosScreen/hooks/usePhotosTimeline.ts | 12 +++- src/services/photos/PhotoCloudBrowser.spec.ts | 68 ++++++++++++++----- src/services/photos/PhotoCloudBrowser.ts | 30 +++++--- src/store/slices/photos/index.spec.ts | 9 ++- src/store/slices/photos/index.ts | 15 +++- 6 files changed, 120 insertions(+), 38 deletions(-) diff --git a/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx b/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx index 9e65707eb..2d624e405 100644 --- a/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx +++ b/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx @@ -1,11 +1,11 @@ -import { renderHook, act } from '@testing-library/react-native'; +import * as useCases from '@internxt-mobile/useCases/drive'; import { configureStore } from '@reduxjs/toolkit'; -import { Provider } from 'react-redux'; +import { act, renderHook } from '@testing-library/react-native'; import React from 'react'; +import { Provider } from 'react-redux'; import asyncStorageService from '../../services/AsyncStorageService'; import photosReducer, { PhotosState } from '../../store/slices/photos'; import { useDiscoverPhotosSheet } from './useDiscoverPhotosSheet'; -import * as useCases from '@internxt-mobile/useCases/drive'; jest.mock('../../services/AsyncStorageService', () => ({ __esModule: true, @@ -26,7 +26,23 @@ const makeWrapper = (photosState?: Partial) => { const store = configureStore({ reducer: { photos: photosReducer }, preloadedState: { - photos: { enabled: false, networkCondition: 'wifi-only', permissionStatus: 'undetermined', syncStatus: 'idle', pendingBackupAssets: 0, totalScannedAssets: 0, totalAssetsUploaded: 0, currentUploadProgress: 0, lastSyncTimestamp: null, uploadingAssetIds: [], deviceId: null, sessionTotalAssets: 0, sessionUploadedAssets: 0, ...photosState }, + photos: { + enabled: false, + networkCondition: 'wifi-only', + permissionStatus: 'undetermined', + syncStatus: 'idle', + pendingBackupAssets: 0, + totalScannedAssets: 0, + totalAssetsUploaded: 0, + currentUploadProgress: 0, + lastSyncTimestamp: null, + uploadingAssetIds: [], + deviceId: null, + sessionTotalAssets: 0, + sessionUploadedAssets: 0, + cloudFetchRevision: 0, + ...photosState, + }, }, }); return ({ children }: { children: React.ReactNode }) => {children}; diff --git a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts index 9cd2ad095..f3eff1263 100644 --- a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts +++ b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts @@ -32,8 +32,14 @@ export const usePhotosTimeline = (): PhotosTimelineResult => { const isLoadingMoreRef = useRef(false); const appStateRef = useRef(AppState.currentState); - const { syncStatus, uploadingAssetIds, sessionTotalAssets, sessionUploadedAssets, lastSyncTimestamp } = - useAppSelector((state) => state.photos); + const { + syncStatus, + uploadingAssetIds, + sessionTotalAssets, + sessionUploadedAssets, + lastSyncTimestamp, + cloudFetchRevision, + } = useAppSelector((state) => state.photos); const fetchLocalPage = useCallback(async (after?: string): Promise> => { return MediaLibrary.getAssetsAsync({ @@ -117,7 +123,7 @@ export const usePhotosTimeline = (): PhotosTimelineResult => { setCloudItems(deduplicated.map(cloudEntryToPhotoItem)); }; loadCloudAssets(); - }, [lastSyncTimestamp]); + }, [lastSyncTimestamp, cloudFetchRevision]); const uploadingIdSet = useMemo(() => new Set(uploadingAssetIds), [uploadingAssetIds]); diff --git a/src/services/photos/PhotoCloudBrowser.spec.ts b/src/services/photos/PhotoCloudBrowser.spec.ts index 178286382..c39f5382f 100644 --- a/src/services/photos/PhotoCloudBrowser.spec.ts +++ b/src/services/photos/PhotoCloudBrowser.spec.ts @@ -27,14 +27,15 @@ const mockFolderService = driveFolderService as jest.Mocked; const mockPhotosLocalDB = photosLocalDB as jest.Mocked; -const makeFolder = (uuid: string, plainName: string) => ({ uuid, plainName, name: plainName } as never); -const makeFile = (uuid: string, plainName: string) => ({ - uuid, - plainName, - name: plainName, - size: 1024, - thumbnails: [{ bucket_id: 'bucket-1', bucket_file: 'file-1', type: 'jpg' }], -} as never); +const makeFolder = (uuid: string, plainName: string) => ({ uuid, plainName, name: plainName }) as never; +const makeFile = (uuid: string, plainName: string) => + ({ + uuid, + plainName, + name: plainName, + size: 1024, + thumbnails: [{ bucket_id: 'bucket-1', bucket_file: 'file-1', type: 'jpg' }], + }) as never; beforeEach(() => { jest.clearAllMocks(); @@ -85,7 +86,12 @@ describe('PhotoCloudBrowser.fetchMonth', () => { const freshTimestamp = Date.now() - 1000; mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(freshTimestamp); - await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); expect(mockFolderService.getFolderFolders).not.toHaveBeenCalled(); expect(mockPhotosLocalDB.upsertCloudAsset).not.toHaveBeenCalled(); @@ -107,7 +113,12 @@ describe('PhotoCloudBrowser.fetchMonth', () => { mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never); - await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); expect(mockPhotosLocalDB.upsertCloudAsset).toHaveBeenCalledTimes(1); expect(mockPhotosLocalDB.upsertCloudAsset).toHaveBeenCalledWith( @@ -127,7 +138,12 @@ describe('PhotoCloudBrowser.fetchMonth', () => { mockFolderService.getFolderFolders.mockResolvedValue({ folders: [] } as never); - await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); expect(mockFolderService.getFolderFolders).toHaveBeenCalled(); }); @@ -136,7 +152,12 @@ describe('PhotoCloudBrowser.fetchMonth', () => { mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(null); mockFolderService.getFolderFolders.mockResolvedValueOnce({ folders: [] } as never); - await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); expect(mockPhotosLocalDB.upsertCloudAsset).not.toHaveBeenCalled(); }); @@ -156,7 +177,12 @@ describe('PhotoCloudBrowser.fetchMonth', () => { mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never); - await photoCloudBrowser.fetchMonth('device-1', 'device-folder-uuid', 2024, 6); + await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); const upsertCall = mockPhotosLocalDB.upsertCloudAsset.mock.calls[0][0]; expect(upsertCall.createdAt).toBe(new Date(2024, 5, 15).getTime()); @@ -172,7 +198,7 @@ describe('PhotoCloudBrowser.syncAllDevicesFromMonth', () => { { uuid: 'd2-uuid', name: 'device-2' }, ]; - await photoCloudBrowser.syncAllDevicesFromMonth(devices, 2024, 6, 3); + await photoCloudBrowser.syncAllDevicesFromMonth({ devices, fromYear: 2024, fromMonth: 6, monthsBack: 3 }); expect(fetchMonthSpy).toHaveBeenCalledTimes(6); fetchMonthSpy.mockRestore(); @@ -183,12 +209,18 @@ describe('PhotoCloudBrowser.syncAllDevicesFromMonth', () => { const devices = [{ uuid: 'd1-uuid', name: 'device-1' }]; - await photoCloudBrowser.syncAllDevicesFromMonth(devices, 2024, 2, 3); + await photoCloudBrowser.syncAllDevicesFromMonth({ devices, fromYear: 2024, fromMonth: 2, monthsBack: 3 }); const calls = fetchMonthSpy.mock.calls; - expect(calls).toContainEqual(['device-1', 'd1-uuid', 2024, 2]); - expect(calls).toContainEqual(['device-1', 'd1-uuid', 2024, 1]); - expect(calls).toContainEqual(['device-1', 'd1-uuid', 2023, 12]); + expect(calls).toContainEqual([ + expect.objectContaining({ deviceId: 'device-1', deviceFolderUuid: 'd1-uuid', year: 2024, month: 2 }), + ]); + expect(calls).toContainEqual([ + expect.objectContaining({ deviceId: 'device-1', deviceFolderUuid: 'd1-uuid', year: 2024, month: 1 }), + ]); + expect(calls).toContainEqual([ + expect.objectContaining({ deviceId: 'device-1', deviceFolderUuid: 'd1-uuid', year: 2023, month: 12 }), + ]); fetchMonthSpy.mockRestore(); }); diff --git a/src/services/photos/PhotoCloudBrowser.ts b/src/services/photos/PhotoCloudBrowser.ts index 6c9557060..f68e6d5e6 100644 --- a/src/services/photos/PhotoCloudBrowser.ts +++ b/src/services/photos/PhotoCloudBrowser.ts @@ -5,8 +5,7 @@ import { photosLocalDB } from './database/photosLocalDB'; import { photoBackupFolders } from './PhotoBackupFolders'; const HOUR_MS = 60 * 60 * 1000; -// TODO: REMEMBER TO CHANGE THE TIME BEFORE PR -const CACHE_TTL_MS = 1 * HOUR_MS; +const CACHE_TTL_MS = 24 * HOUR_MS; const PAGE_SIZE = 50; const fetchAllPages = async (fetcher: (offset: number) => Promise): Promise => { @@ -38,7 +37,14 @@ class PhotoCloudBrowserService { return folders.map((f) => ({ uuid: f.uuid, name: f.plainName ?? '' })); } - async fetchMonth(deviceId: string, deviceFolderUuid: string, year: number, month: number): Promise { + async fetchMonth(params: { + deviceId: string; + deviceFolderUuid: string; + year: number; + month: number; + onMonthFetched?: () => void; + }): Promise { + const { deviceId, deviceFolderUuid, year, month, onMonthFetched } = params; const cacheAge = await this.localDB.getCloudFetchCacheAge(deviceId, year, month); if (cacheAge !== null && Date.now() - cacheAge < CACHE_TTL_MS) return; @@ -76,14 +82,18 @@ class PhotoCloudBrowserService { }); } } + + onMonthFetched?.(); } - async syncAllDevicesFromMonth( - devices: { uuid: string; name: string }[], - fromYear: number, - fromMonth: number, - monthsBack = 12, - ): Promise { + async syncAllDevicesFromMonth(params: { + devices: { uuid: string; name: string }[]; + fromYear: number; + fromMonth: number; + monthsBack?: number; + onMonthFetched?: () => void; + }): Promise { + const { devices, fromYear, fromMonth, monthsBack = 12, onMonthFetched } = params; for (let i = 0; i < monthsBack; i++) { let year = fromYear; let month = fromMonth - i; @@ -92,7 +102,7 @@ class PhotoCloudBrowserService { year -= 1; } for (const device of devices) { - await this.fetchMonth(device.name, device.uuid, year, month); + await this.fetchMonth({ deviceId: device.name, deviceFolderUuid: device.uuid, year, month, onMonthFetched }); } } } diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts index c1b426464..43e9ac7c4 100644 --- a/src/store/slices/photos/index.spec.ts +++ b/src/store/slices/photos/index.spec.ts @@ -143,6 +143,7 @@ describe('photos slice', () => { deviceId: null, sessionTotalAssets: 0, sessionUploadedAssets: 0, + cloudFetchRevision: 0, }; mockAsyncStorage.getItem.mockResolvedValueOnce(JSON.stringify(saved)); @@ -450,7 +451,13 @@ describe('photos slice', () => { await store.dispatch(runCloudMetadataSyncThunk()); - expect(mockCloudBrowser.syncAllDevicesFromMonth).toHaveBeenCalledWith(devices, expect.any(Number), expect.any(Number), 12); + expect(mockCloudBrowser.syncAllDevicesFromMonth).toHaveBeenCalledWith({ + devices, + fromYear: expect.any(Number), + fromMonth: expect.any(Number), + monthsBack: 12, + onMonthFetched: expect.any(Function), + }); }); test('when the cloud sync step fails during a backup cycle, then the cycle still completes without throwing', async () => { diff --git a/src/store/slices/photos/index.ts b/src/store/slices/photos/index.ts index ea7dc7f6d..723103410 100644 --- a/src/store/slices/photos/index.ts +++ b/src/store/slices/photos/index.ts @@ -33,6 +33,7 @@ export interface PhotosState { deviceId: string | null; sessionTotalAssets: number; sessionUploadedAssets: number; + cloudFetchRevision: number; } const initialState: PhotosState = { @@ -49,6 +50,7 @@ const initialState: PhotosState = { deviceId: null, sessionTotalAssets: 0, sessionUploadedAssets: 0, + cloudFetchRevision: 0, }; const persistPhotosSettings = async (state: PhotosState): Promise => { @@ -222,7 +224,7 @@ export const runUploadThunk = createAsyncThunk export const runCloudMetadataSyncThunk = createAsyncThunk( 'photos/runCloudMetadataSync', - async (_, { getState }) => { + async (_, { getState, dispatch }) => { const { enabled, deviceId } = getState().photos; if (!enabled || !deviceId) return; @@ -231,7 +233,13 @@ export const runCloudMetadataSyncThunk = createAsyncThunk dispatch(photosSlice.actions.incrementCloudFetchRevision()), + }); }, ); @@ -337,6 +345,9 @@ export const photosSlice = createSlice({ incrementSessionUploadedAssets: (state) => { state.sessionUploadedAssets += 1; }, + incrementCloudFetchRevision: (state) => { + state.cloudFetchRevision += 1; + }, }, }); From a3c0da176d8f34df327d5e1bd86d7f6f4ec7cbc7 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 7 May 2026 18:18:56 +0200 Subject: [PATCH 4/4] Fix sonar cloud issues --- src/components/modals/SignOutModal/index.tsx | 2 +- src/navigation/index.tsx | 2 +- src/services/photos/PhotoCloudBrowser.ts | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/modals/SignOutModal/index.tsx b/src/components/modals/SignOutModal/index.tsx index 855024dcf..56e1c3fbf 100644 --- a/src/components/modals/SignOutModal/index.tsx +++ b/src/components/modals/SignOutModal/index.tsx @@ -11,7 +11,7 @@ import UserProfilePicture from '../../UserProfilePicture'; import CenterModal from '../CenterModal'; interface SignOutModalProps { - onSignedOut: () => void; + readonly onSignedOut: () => void; } function SignOutModal({ onSignedOut }: SignOutModalProps): JSX.Element { diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 7396b3dbf..29591708c 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -7,7 +7,7 @@ import LinkingConfiguration from './LinkingConfiguration'; import RootNavigator from './RootNavigator'; interface NavigationProps { - navigationRef: NavigationContainerRefWithCurrent; + readonly navigationRef: NavigationContainerRefWithCurrent; } export default function Navigation({ navigationRef }: NavigationProps) { diff --git a/src/services/photos/PhotoCloudBrowser.ts b/src/services/photos/PhotoCloudBrowser.ts index f68e6d5e6..699375fc9 100644 --- a/src/services/photos/PhotoCloudBrowser.ts +++ b/src/services/photos/PhotoCloudBrowser.ts @@ -22,9 +22,9 @@ const fetchAllPages = async (fetcher: (offset: number) => Promise): Prom class PhotoCloudBrowserService { constructor( - private backupFolders: typeof photoBackupFolders, - private folderService: typeof driveFolderService, - private localDB: typeof photosLocalDB, + private readonly backupFolders: typeof photoBackupFolders, + private readonly folderService: typeof driveFolderService, + private readonly localDB: typeof photosLocalDB, ) {} async listDeviceFolders(): Promise<{ uuid: string; name: string }[]> { @@ -59,8 +59,8 @@ class PhotoCloudBrowserService { const now = Date.now(); for (const dayFolder of dayFolders) { - const day = parseInt(dayFolder.plainName ?? '', 10); - const folderDate = new Date(year, month - 1, isNaN(day) ? 1 : day).getTime(); + const day = Number.parseInt(dayFolder.plainName ?? '', 10); + const folderDate = new Date(year, month - 1, Number.isNaN(day) ? 1 : day).getTime(); const files = await this.listFilesWithThumbnails(dayFolder.uuid); for (const file of files) {