diff --git a/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx b/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx index 2d624e405..18d816132 100644 --- a/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx +++ b/src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx @@ -41,6 +41,7 @@ const makeWrapper = (photosState?: Partial) => { sessionTotalAssets: 0, sessionUploadedAssets: 0, cloudFetchRevision: 0, + isFetchingCloudHistory: false, ...photosState, }, }, diff --git a/src/screens/PhotosScreen/components/PhotoItem.tsx b/src/screens/PhotosScreen/components/PhotoItem.tsx index 839d08c76..ef5b4a6ec 100644 --- a/src/screens/PhotosScreen/components/PhotoItem.tsx +++ b/src/screens/PhotosScreen/components/PhotoItem.tsx @@ -76,6 +76,21 @@ const SelectOverlay = ({ ); }; +const localPhotoCellAreEqual = ( + prev: CellProps & { item: PhotoItemType }, + next: CellProps & { item: PhotoItemType }, +) => + prev.item.id === next.item.id && + prev.item.backupState === next.item.backupState && + prev.item.uri === next.item.uri && + prev.item.uploadProgress === next.item.uploadProgress && + prev.item.mediaType === next.item.mediaType && + prev.item.duration === next.item.duration && + prev.isSelectMode === next.isSelectMode && + prev.isSelected === next.isSelected && + prev.onPress === next.onPress && + prev.onLongPress === next.onLongPress; + const LocalPhotoCell = memo( ({ item, isSelectMode, isSelected, onPress, onLongPress }: CellProps & { item: PhotoItemType }): JSX.Element => { const tailwind = useTailwind(); @@ -92,7 +107,8 @@ const LocalPhotoCell = memo( return ( - + {/* key forces Image to remount on cell recycle, preventing the previous photo from flashing */} + {(item.backupState === 'not-backed' || item.backupState === 'uploading') && ( @@ -130,6 +146,7 @@ const LocalPhotoCell = memo( ); }, + localPhotoCellAreEqual, ); const CloudPhotoCell = memo( diff --git a/src/screens/PhotosScreen/hooks/useCloudAssets.spec.ts b/src/screens/PhotosScreen/hooks/useCloudAssets.spec.ts new file mode 100644 index 000000000..0e0b60aaf --- /dev/null +++ b/src/screens/PhotosScreen/hooks/useCloudAssets.spec.ts @@ -0,0 +1,181 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { useAppSelector } from 'src/store/hooks'; +import { useCloudAssets } from './useCloudAssets'; + +jest.useFakeTimers(); + +jest.mock('src/services/photos/database/photosLocalDB', () => ({ + photosLocalDB: { + init: jest.fn().mockResolvedValue(undefined), + getAllCloudAssets: jest.fn(), + getSyncedRemoteFileIds: jest.fn(), + }, +})); + +jest.mock('src/store/hooks', () => ({ + useAppSelector: jest.fn(), +})); + +const mockPhotosLocalDB = photosLocalDB as jest.Mocked; +const mockUseAppSelector = useAppSelector as jest.Mock; + +const makeStoreState = (overrides: { lastSyncTimestamp?: number | null; cloudFetchRevision?: number } = {}) => ({ + lastSyncTimestamp: overrides.lastSyncTimestamp ?? null, + cloudFetchRevision: overrides.cloudFetchRevision ?? 0, +}); + +const flushAsync = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + mockPhotosLocalDB.getAllCloudAssets.mockResolvedValue([]); + mockPhotosLocalDB.getSyncedRemoteFileIds.mockResolvedValue(new Set()); + mockUseAppSelector.mockImplementation((selector: (s: { photos: ReturnType }) => unknown) => + selector({ photos: makeStoreState() }), + ); +}); + +describe('useCloudAssets', () => { + test('when the hook mounts, then cloud items are loaded from the database', async () => { + mockPhotosLocalDB.getAllCloudAssets.mockResolvedValueOnce([ + { + remoteFileId: 'r1', + deviceId: 'device-1', + createdAt: 1000, + fileName: 'photo.jpg', + fileSize: null, + thumbnailPath: null, + thumbnailBucketId: null, + thumbnailBucketFile: null, + thumbnailType: null, + discoveredAt: 1000, + }, + ]); + + const { result } = renderHook(() => useCloudAssets()); + + // Flush the immediate lastSyncTimestamp effect only — do not run timers + // so the debounce from cloudFetchRevision has not yet fired + await act(flushAsync); + + expect(result.current.cloudItems).toHaveLength(1); + expect(result.current.cloudItems[0].id).toBe('r1'); + }); + + test('when cloud fetch revision increments, then cloud items reload from the database after debounce', async () => { + mockPhotosLocalDB.getAllCloudAssets + .mockResolvedValueOnce([]) // immediate mount effect + .mockResolvedValueOnce([ + { + remoteFileId: 'r2', + deviceId: 'device-1', + createdAt: 2000, + fileName: 'photo2.jpg', + fileSize: null, + thumbnailPath: null, + thumbnailBucketId: null, + thumbnailBucketFile: null, + thumbnailType: null, + discoveredAt: 2000, + }, + ]); + + const { result, rerender } = renderHook(() => useCloudAssets()); + + await act(flushAsync); + expect(result.current.cloudItems).toHaveLength(0); + + mockUseAppSelector.mockImplementation((selector: (s: { photos: ReturnType }) => unknown) => + selector({ photos: makeStoreState({ cloudFetchRevision: 1 }) }), + ); + + await act(async () => { + rerender({}); + // Advance past the 500ms debounce, then flush the async reload + jest.runAllTimers(); + await flushAsync(); + }); + + expect(result.current.cloudItems).toHaveLength(1); + expect(result.current.cloudItems[0].id).toBe('r2'); + }); + + test('when last sync timestamp updates, then cloud items reload from the database immediately', async () => { + mockPhotosLocalDB.getAllCloudAssets + .mockResolvedValueOnce([]) // mount + .mockResolvedValueOnce([ + { + remoteFileId: 'r3', + deviceId: 'device-1', + createdAt: 3000, + fileName: 'photo3.jpg', + fileSize: null, + thumbnailPath: null, + thumbnailBucketId: null, + thumbnailBucketFile: null, + thumbnailType: null, + discoveredAt: 3000, + }, + ]); + + const { result, rerender } = renderHook(() => useCloudAssets()); + + await act(flushAsync); + expect(result.current.cloudItems).toHaveLength(0); + + mockUseAppSelector.mockImplementation((selector: (s: { photos: ReturnType }) => unknown) => + selector({ photos: makeStoreState({ lastSyncTimestamp: Date.now() }) }), + ); + + // lastSyncTimestamp effect is immediate — no timer needed + await act(async () => { + rerender({}); + await flushAsync(); + }); + + expect(result.current.cloudItems).toHaveLength(1); + expect(result.current.cloudItems[0].id).toBe('r3'); + }); + + test('when synced remote ids overlap with cloud assets, then duplicates are excluded', async () => { + mockPhotosLocalDB.getAllCloudAssets.mockResolvedValueOnce([ + { + remoteFileId: 'r1', + deviceId: 'device-1', + createdAt: 1000, + fileName: 'photo.jpg', + fileSize: null, + thumbnailPath: null, + thumbnailBucketId: null, + thumbnailBucketFile: null, + thumbnailType: null, + discoveredAt: 1000, + }, + { + remoteFileId: 'r2', + deviceId: 'device-1', + createdAt: 2000, + fileName: 'photo2.jpg', + fileSize: null, + thumbnailPath: null, + thumbnailBucketId: null, + thumbnailBucketFile: null, + thumbnailType: null, + discoveredAt: 2000, + }, + ]); + mockPhotosLocalDB.getSyncedRemoteFileIds.mockResolvedValueOnce(new Set(['r1'])); + + const { result } = renderHook(() => useCloudAssets()); + + await act(flushAsync); + + expect(result.current.cloudItems).toHaveLength(1); + expect(result.current.cloudItems[0].id).toBe('r2'); + }); +}); diff --git a/src/screens/PhotosScreen/hooks/useCloudAssets.ts b/src/screens/PhotosScreen/hooks/useCloudAssets.ts new file mode 100644 index 000000000..1d06efc88 --- /dev/null +++ b/src/screens/PhotosScreen/hooks/useCloudAssets.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { useAppSelector } from 'src/store/hooks'; +import { CloudPhotoItem } from '../types'; +import { cloudEntryToPhotoItem } from '../utils/photoTimelineGroups'; + +export interface CloudAssetsResult { + cloudItems: CloudPhotoItem[]; +} + +export const useCloudAssets = (): CloudAssetsResult => { + const [cloudItems, setCloudItems] = useState([]); + const { lastSyncTimestamp, cloudFetchRevision } = useAppSelector((state) => state.photos); + const debounceRef = useRef | null>(null); + + const reloadCloudFromDB = useCallback(async () => { + await photosLocalDB.init(); + const [allCloud, syncedRemoteIds] = await Promise.all([ + photosLocalDB.getAllCloudAssets(), + photosLocalDB.getSyncedRemoteFileIds(), + ]); + const deduplicated = allCloud.filter((cloudEntry) => !syncedRemoteIds.has(cloudEntry.remoteFileId)); + setCloudItems(deduplicated.map(cloudEntryToPhotoItem)); + }, []); + + // Immediate reload when the backup cycle completes + useEffect(() => { + reloadCloudFromDB(); + }, [reloadCloudFromDB, lastSyncTimestamp]); + + // Debounced reload during cloud history sync — parallel workers can fire many + // rapid increments; coalescing them prevents FlashList from reconciling 2000+ + // item lists on every month completion + useEffect(() => { + debounceRef.current = setTimeout(reloadCloudFromDB, 500); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [reloadCloudFromDB, cloudFetchRevision]); + + return { cloudItems }; +}; diff --git a/src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts b/src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts new file mode 100644 index 000000000..918d890a0 --- /dev/null +++ b/src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts @@ -0,0 +1,177 @@ +import { act, renderHook } from '@testing-library/react-native'; +import * as MediaLibrary from 'expo-media-library'; +import { AppState } from 'react-native'; +import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { useLocalAssets } from './useLocalAssets'; + +jest.mock('expo-media-library', () => ({ + MediaType: { photo: 'photo', video: 'video' }, + SortBy: { creationTime: 'creationTime' }, + getAssetsAsync: jest.fn(), +})); + +jest.mock('src/services/photos/database/photosLocalDB', () => ({ + photosLocalDB: { + init: jest.fn().mockResolvedValue(undefined), + getSyncedEntries: jest.fn(), + }, +})); + +jest.mock('src/store/hooks', () => ({ + useAppSelector: jest.fn().mockReturnValue({ + syncStatus: 'idle', + uploadingAssetIds: [], + sessionUploadedAssets: 0, + }), +})); + +const mockMediaLibrary = MediaLibrary as jest.Mocked; +const mockPhotosLocalDB = photosLocalDB as jest.Mocked; + +const makeAsset = (id: string): MediaLibrary.Asset => + ({ id, uri: `file://${id}.jpg`, creationTime: 1000, mediaType: 'photo' }) as never; + +const makePage = ( + assets: MediaLibrary.Asset[], + hasNextPage = false, + endCursor = 'cursor-1', +): MediaLibrary.PagedInfo => ({ assets, hasNextPage, endCursor, totalCount: assets.length }); + +beforeEach(() => { + jest.clearAllMocks(); + mockPhotosLocalDB.getSyncedEntries.mockResolvedValue(new Map()); +}); + +describe('useLocalAssets', () => { + test('when the hook mounts, then the first page of local assets is loaded', async () => { + const assets = [makeAsset('a1'), makeAsset('a2')]; + mockMediaLibrary.getAssetsAsync.mockResolvedValueOnce(makePage(assets)); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.assets).toEqual(assets); + expect(result.current.isLoading).toBe(false); + }); + + test('when the first page loads, then loading is set to false', async () => { + mockMediaLibrary.getAssetsAsync.mockResolvedValueOnce(makePage([])); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLoading).toBe(false); + }); + + test('when loadNextPage is called and there is a next page, then the new assets are appended', async () => { + const firstAssets = [makeAsset('a1')]; + const secondAssets = [makeAsset('a2')]; + mockMediaLibrary.getAssetsAsync + .mockResolvedValueOnce(makePage(firstAssets, true, 'cursor-1')) + .mockResolvedValueOnce(makePage(secondAssets, false)); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + result.current.loadNextPage(); + await Promise.resolve(); + }); + + expect(result.current.assets).toEqual([...firstAssets, ...secondAssets]); + }); + + test('when loadNextPage is called but there is no next page, then no additional fetch is made', async () => { + mockMediaLibrary.getAssetsAsync.mockResolvedValueOnce(makePage([makeAsset('a1')], false)); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + result.current.loadNextPage(); + await Promise.resolve(); + }); + + expect(mockMediaLibrary.getAssetsAsync).toHaveBeenCalledTimes(1); + }); + + test('when the app returns to the foreground, then assets reload from the start', async () => { + const firstLoad = [makeAsset('a1')]; + const reloadAssets = [makeAsset('a2'), makeAsset('a3')]; + mockMediaLibrary.getAssetsAsync + .mockResolvedValueOnce(makePage(firstLoad)) + .mockResolvedValueOnce(makePage(reloadAssets)); + + let appStateCallback: ((state: string) => void) | undefined; + jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, cb) => { + appStateCallback = cb as (state: string) => void; + return { remove: jest.fn() } as never; + }); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + appStateCallback?.('background'); + appStateCallback?.('active'); + await Promise.resolve(); + }); + + expect(result.current.assets).toEqual(reloadAssets); + }); + + test('when the first page has more pages, then all remaining pages are loaded on mount without waiting for a scroll', async () => { + const page1 = [makeAsset('a1'), makeAsset('a2')]; + const page2 = [makeAsset('a3'), makeAsset('a4')]; + const page3 = [makeAsset('a5')]; + + mockMediaLibrary.getAssetsAsync + .mockResolvedValueOnce(makePage(page1, true, 'cursor-1')) + .mockResolvedValueOnce(makePage(page2, true, 'cursor-2')) + .mockResolvedValueOnce(makePage(page3, false)); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); // first page + await act(async () => { + await Promise.resolve(); + }); // page 2 (eager) + await act(async () => { + await Promise.resolve(); + }); // page 3 (eager) + + expect(result.current.assets).toEqual([...page1, ...page2, ...page3]); + expect(mockMediaLibrary.getAssetsAsync).toHaveBeenCalledTimes(3); + }); + + test('when sync status changes and there are assets loaded, then synced ids refresh from the database', async () => { + mockMediaLibrary.getAssetsAsync.mockResolvedValueOnce(makePage([makeAsset('a1')])); + mockPhotosLocalDB.getSyncedEntries.mockResolvedValue(new Map([['a1', { modificationTime: null }]])); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockPhotosLocalDB.getSyncedEntries).toHaveBeenCalledWith(['a1']); + expect(result.current.syncedIds.has('a1')).toBe(true); + }); +}); diff --git a/src/screens/PhotosScreen/hooks/useLocalAssets.ts b/src/screens/PhotosScreen/hooks/useLocalAssets.ts new file mode 100644 index 000000000..9937a14c9 --- /dev/null +++ b/src/screens/PhotosScreen/hooks/useLocalAssets.ts @@ -0,0 +1,132 @@ +import * as MediaLibrary from 'expo-media-library'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AppState } from 'react-native'; +import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { useAppSelector } from 'src/store/hooks'; + +const PAGE_SIZE = 200; +const MEDIA_TYPES = [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video]; + +export interface LocalAssetsResult { + assets: MediaLibrary.Asset[]; + isLoading: boolean; + syncedIds: Set; + uploadingIdSet: Set; + loadNextPage: () => void; +} + +export const useLocalAssets = (): LocalAssetsResult => { + const [assets, setAssets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [syncedIds, setSyncedIds] = useState>(new Set()); + + const cursorRef = useRef(undefined); + const hasMoreRef = useRef(true); + const isLoadingMoreRef = useRef(false); + const appStateRef = useRef(AppState.currentState); + + const { syncStatus, uploadingAssetIds, sessionUploadedAssets } = useAppSelector((state) => state.photos); + + const uploadingIdSet = useMemo(() => new Set(uploadingAssetIds), [uploadingAssetIds]); + + const fetchLocalPage = useCallback( + async (after?: string): Promise> => + MediaLibrary.getAssetsAsync({ + first: PAGE_SIZE, + after, + mediaType: MEDIA_TYPES, + sortBy: [[MediaLibrary.SortBy.creationTime, false]], + }), + [], + ); + + 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); + try { + const page = await fetchLocalPage(cursorRef.current); + applyPage(page, { replace: false }); + } finally { + isLoadingMoreRef.current = false; + setIsLoading(false); + } + }, [fetchLocalPage, applyPage]); + + const loadAllRemainingPages = useCallback(async () => { + if (isLoadingMoreRef.current || !hasMoreRef.current || !cursorRef.current) { + return; + } + isLoadingMoreRef.current = true; + try { + while (hasMoreRef.current && cursorRef.current) { + const page = await fetchLocalPage(cursorRef.current); + applyPage(page, { replace: false }); + } + } finally { + isLoadingMoreRef.current = false; + } + }, [fetchLocalPage, applyPage]); + + const refreshSyncStatusFromDB = useCallback(async () => { + if (assets.length === 0) { + return; + } + const assetIds = assets.map((a) => a.id); + await photosLocalDB.init(); + const entries = await photosLocalDB.getSyncedEntries(assetIds); + setSyncedIds(new Set(entries.keys())); + }, [assets]); + + const reloadFromStart = useCallback(async () => { + cursorRef.current = undefined; + hasMoreRef.current = true; + const page = await fetchLocalPage(); + applyPage(page, { replace: true }); + }, [fetchLocalPage, applyPage]); + + useEffect(() => { + const loadFirstPage = async () => { + try { + const page = await fetchLocalPage(); + applyPage(page, { replace: true }); + // Eagerly load all remaining pages in background — don't wait for scroll. + // Cloud items from history can extend the list far back in time, making + // onEndReached fire much later than the user runs out of local assets. + if (page.hasNextPage) { + loadAllRemainingPages(); + } + } finally { + setIsLoading(false); + } + }; + loadFirstPage(); + }, []); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState) => { + if (appStateRef.current !== 'active' && nextState === 'active') { + reloadFromStart(); + } + appStateRef.current = nextState; + }); + return () => subscription.remove(); + }, [reloadFromStart]); + + useEffect(() => { + refreshSyncStatusFromDB(); + }, [refreshSyncStatusFromDB, syncStatus, sessionUploadedAssets]); + + return { assets, isLoading, syncedIds, uploadingIdSet, loadNextPage }; +}; diff --git a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts index f3eff1263..8ccc0e0b7 100644 --- a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts +++ b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts @@ -1,19 +1,9 @@ -import * as MediaLibrary from 'expo-media-library'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { AppState } from 'react-native'; -import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; +import { useMemo } from 'react'; import { useAppSelector } from 'src/store/hooks'; import { TimelineDateGroup } from '../components/PhotosTimeline'; -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]; +import { getGroupSyncStatus, groupAssetsByDate, mergeCloudIntoGroups } from '../utils/photoTimelineGroups'; +import { useCloudAssets } from './useCloudAssets'; +import { useLocalAssets } from './useLocalAssets'; export interface PhotosTimelineResult { timelineDateGroups: TimelineDateGroup[]; @@ -22,121 +12,28 @@ export interface PhotosTimelineResult { } 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, - lastSyncTimestamp, - cloudFetchRevision, - } = useAppSelector((state) => state.photos); + const { assets, isLoading, syncedIds, uploadingIdSet, loadNextPage } = useLocalAssets(); + const { cloudItems } = useCloudAssets(); - const fetchLocalPage = useCallback(async (after?: string): Promise> => { - return MediaLibrary.getAssetsAsync({ - first: PAGE_SIZE, - after, - mediaType: MEDIA_TYPES, - sortBy: [[MediaLibrary.SortBy.creationTime, false]], - }); - }, []); - - 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 { syncStatus, sessionTotalAssets, sessionUploadedAssets, isFetchingCloudHistory } = useAppSelector( + (state) => state.photos, ); - const loadNextPage = useCallback(async () => { - if (isLoadingMoreRef.current || !hasMoreRef.current || !cursorRef.current) return; - isLoadingMoreRef.current = true; - setIsLoading(true); - const page = await fetchLocalPage(cursorRef.current); - applyPage(page, { replace: false }); - isLoadingMoreRef.current = false; - setIsLoading(false); - }, [fetchLocalPage, applyPage]); - - const refreshSyncStatusFromDB = useCallback(async () => { - if (assets.length === 0) { - return; - } - const assetIds = assets.map((asset) => asset.id); - await photosLocalDB.init(); - const entries = await photosLocalDB.getSyncedEntries(assetIds); - setSyncedIds(new Set(entries.keys())); - }, [assets]); - - const reloadFromStart = useCallback(async () => { - cursorRef.current = undefined; - hasMoreRef.current = true; - const page = await fetchLocalPage(); - applyPage(page, { replace: true }); - }, [fetchLocalPage, applyPage]); - - useEffect(() => { - const loadFirstPage = async () => { - try { - const page = await fetchLocalPage(); - applyPage(page, { replace: true }); - } finally { - setIsLoading(false); - } - }; - loadFirstPage(); - }, []); - - useEffect(() => { - const subscription = AppState.addEventListener('change', (nextState) => { - if (appStateRef.current !== 'active' && nextState === 'active') { - reloadFromStart(); - } - appStateRef.current = nextState; - }); - return () => subscription.remove(); - }, [reloadFromStart]); - - useEffect(() => { - refreshSyncStatusFromDB(); - }, [refreshSyncStatusFromDB, syncStatus, sessionUploadedAssets]); - - 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, cloudFetchRevision]); + const localGroups = useMemo( + () => groupAssetsByDate(assets, syncedIds, uploadingIdSet), + [assets, syncedIds, uploadingIdSet], + ); - const uploadingIdSet = useMemo(() => new Set(uploadingAssetIds), [uploadingAssetIds]); + const mergedGroups = useMemo(() => mergeCloudIntoGroups(localGroups, cloudItems), [localGroups, cloudItems]); const timelineDateGroups = useMemo(() => { 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, cloudItems, syncStatus, sessionTotalAssets, sessionUploadedAssets]); + syncStatus: getGroupSyncStatus(group, syncStatus, remainingCount, backupProgress, isFetchingCloudHistory), + })) as TimelineDateGroup[]; + }, [mergedGroups, syncStatus, sessionTotalAssets, sessionUploadedAssets, isFetchingCloudHistory]); return { timelineDateGroups, isLoading, loadNextPage }; }; diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts index e33203d54..44731873d 100644 --- a/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts +++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.spec.ts @@ -1,8 +1,8 @@ import * as MediaLibrary from 'expo-media-library'; +import { CloudAssetEntry } from 'src/services/photos/database/photosLocalDB'; 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, @@ -172,27 +172,27 @@ describe('getGroupSyncStatus', () => { const group = makeDateGroup({ photos: [makePhotoItem(), makePhotoItem()] }); test('when sync status is scanning, then returns a scanning status', () => { - expect(getGroupSyncStatus(group, 'scanning', 0, undefined)).toEqual({ type: 'scanning' }); + expect(getGroupSyncStatus(group, 'scanning', 0, undefined, false)).toEqual({ type: 'scanning' }); }); test('when sync status is uploading, then returns uploading with remaining count and progress', () => { - expect(getGroupSyncStatus(group, 'uploading', 3, 0.5)).toEqual({ + expect(getGroupSyncStatus(group, 'uploading', 3, 0.5, false)).toEqual({ type: 'uploading', count: 3, backupProgress: 0.5, }); }); - test('when sync status is fetching-cloud, then returns a fetching status', () => { - expect(getGroupSyncStatus(group, 'fetching-cloud', 0, undefined)).toEqual({ type: 'fetching' }); + test('when isFetchingCloudHistory is true and sync status is idle, then returns a fetching status', () => { + expect(getGroupSyncStatus(group, 'idle', 0, undefined, true)).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 }); + expect(getGroupSyncStatus(group, 'idle', 0, undefined, false)).toEqual({ type: 'count', count: 2 }); }); test('when sync status is synced, then returns a count equal to the number of photos in the group', () => { - expect(getGroupSyncStatus(group, 'synced', 0, undefined)).toEqual({ type: 'count', count: 2 }); + expect(getGroupSyncStatus(group, 'synced', 0, undefined, false)).toEqual({ type: 'count', count: 2 }); }); }); @@ -261,7 +261,12 @@ describe('cloudEntryToPhotoItem', () => { 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' }), + 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'); diff --git a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts index d24513bc4..4ae65e653 100644 --- a/src/screens/PhotosScreen/utils/photoTimelineGroups.ts +++ b/src/screens/PhotosScreen/utils/photoTimelineGroups.ts @@ -85,15 +85,15 @@ export const getGroupSyncStatus = ( syncStatus: PhotoSyncStatus, remainingCount: number, backupProgress: number | undefined, + isFetchingCloudHistory: boolean, ): GroupSyncStatus => { switch (syncStatus) { case 'scanning': return { type: 'scanning' }; case 'uploading': return { type: 'uploading', count: remainingCount, backupProgress }; - case 'fetching-cloud': - return { type: 'fetching' }; default: + if (isFetchingCloudHistory) return { type: 'fetching' }; return { type: 'count', count: group.photos.length }; } }; @@ -115,20 +115,33 @@ export const mergeCloudIntoGroups = (localGroups: PhotoDateGroup[], cloudItems: if (cloudItems.length === 0) return localGroups; const now = new Date(); - const groupMap = new Map(localGroups.map((g) => [g.id, { ...g, photos: [...g.photos] }])); + const cloudByKey = new Map(); 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); + const key = new Date(item.createdAt).toDateString(); + let list = cloudByKey.get(key); + if (!list) { + list = []; + cloudByKey.set(key, list); } - group.photos.push(item); + list.push(item); + } + + const processedKeys = new Set(); + const result: PhotoDateGroup[] = localGroups.map((group) => { + processedKeys.add(group.id); + const extra = cloudByKey.get(group.id); + if (!extra) return group; // no cloud additions — preserve reference so FlashList skips these cells + return { ...group, photos: [...group.photos, ...extra] }; + }); + + for (const [key, photos] of cloudByKey) { + if (processedKeys.has(key)) continue; + const date = new Date(key); + result.push({ id: key, label: getDateLabel(date, now), photos }); } - return Array.from(groupMap.values()).sort((a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()); + return result.sort((a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()); }; export type FlatItem = diff --git a/src/services/photos/PhotoCloudBrowser.spec.ts b/src/services/photos/PhotoCloudBrowser.spec.ts index c39f5382f..a8818fbe4 100644 --- a/src/services/photos/PhotoCloudBrowser.spec.ts +++ b/src/services/photos/PhotoCloudBrowser.spec.ts @@ -189,39 +189,199 @@ describe('PhotoCloudBrowser.fetchMonth', () => { }); }); -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); +describe('PhotoCloudBrowser.fetchMonth — return value', () => { + test('when the cache for the given month is still fresh, then zero is returned without fetching', async () => { + const freshTimestamp = Date.now() - 1000; + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(freshTimestamp); - const devices = [ - { uuid: 'd1-uuid', name: 'device-1' }, - { uuid: 'd2-uuid', name: 'device-2' }, - ]; + const result = await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); - await photoCloudBrowser.syncAllDevicesFromMonth({ devices, fromYear: 2024, fromMonth: 6, monthsBack: 3 }); + expect(result).toBe(0); + }); - expect(fetchMonthSpy).toHaveBeenCalledTimes(6); - fetchMonthSpy.mockRestore(); + test('when the year folder does not exist, then zero is returned', async () => { + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(null); + mockFolderService.getFolderFolders.mockResolvedValueOnce({ folders: [] } as never); + + const result = await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); + + expect(result).toBe(0); }); - test('when the month range crosses a year boundary, then the year is decremented correctly', async () => { - const fetchMonthSpy = jest.spyOn(photoCloudBrowser, 'fetchMonth').mockResolvedValue(undefined); + test('when a day folder has two files, then two is returned', async () => { + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(null); - const devices = [{ uuid: 'd1-uuid', name: 'device-1' }]; + const yearFolder = makeFolder('year-uuid', '2024'); + const monthFolder = makeFolder('month-uuid', '06'); + const dayFolder = makeFolder('day-uuid', '15'); + const fileA = makeFile('file-a', 'photo-a.jpg'); + const fileB = makeFile('file-b', 'photo-b.jpg'); - await photoCloudBrowser.syncAllDevicesFromMonth({ devices, fromYear: 2024, fromMonth: 2, monthsBack: 3 }); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [yearFolder] } as never) + .mockResolvedValueOnce({ folders: [monthFolder] } as never) + .mockResolvedValueOnce({ folders: [dayFolder] } as never); - const calls = fetchMonthSpy.mock.calls; - 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 }), - ]); + mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [fileA, fileB] } as never); + + const result = await photoCloudBrowser.fetchMonth({ + deviceId: 'device-1', + deviceFolderUuid: 'device-folder-uuid', + year: 2024, + month: 6, + }); + + expect(result).toBe(2); + }); +}); + +describe('PhotoCloudBrowser.syncAllHistory', () => { + test('when there are no device folders, then no fetches happen', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce(null); + + await photoCloudBrowser.syncAllHistory({}); + + expect(mockPhotosLocalDB.upsertCloudAsset).not.toHaveBeenCalled(); + expect(mockPhotosLocalDB.getCloudFetchCacheAge).not.toHaveBeenCalled(); + }); + + test('when devices have year and month subfolders, then every discovered month triggers an upsert flow', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const device = makeFolder('d1-uuid', 'device-1'); + const yearFolder = makeFolder('year-uuid', '2024'); + const monthA = makeFolder('mA-uuid', '06'); + const monthB = makeFolder('mB-uuid', '03'); + const day = makeFolder('day-uuid', '15'); + const file = makeFile('file-uuid', 'photo.jpg'); + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(null); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [device] } as never) + .mockResolvedValueOnce({ folders: [yearFolder] } as never) + .mockResolvedValueOnce({ folders: [monthA, monthB] } as never) + .mockResolvedValueOnce({ folders: [day] } as never) + .mockResolvedValueOnce({ folders: [day] } as never); + mockFolderService.getFolderContentByUuid.mockResolvedValue({ files: [file] } as never); + + await photoCloudBrowser.syncAllHistory({}); + + expect(mockPhotosLocalDB.upsertCloudAsset).toHaveBeenCalledTimes(2); + }); + + test('when discovery returns months across two years, then results are processed in newest-first order', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const device = makeFolder('d1-uuid', 'device-1'); + const year2023 = makeFolder('y23-uuid', '2023'); + const year2024 = makeFolder('y24-uuid', '2024'); + const m6_2023 = makeFolder('m6-23', '06'); + const m3_2024 = makeFolder('m3-24', '03'); + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(null); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [device] } as never) + .mockResolvedValueOnce({ folders: [year2023, year2024] } as never) + .mockResolvedValueOnce({ folders: [m6_2023] } as never) + .mockResolvedValueOnce({ folders: [m3_2024] } as never) + .mockResolvedValue({ folders: [] } as never); + + await photoCloudBrowser.syncAllHistory({}); + + expect(mockPhotosLocalDB.getCloudFetchCacheAge.mock.calls[0]).toEqual(['device-1', 2024, 3]); + expect(mockPhotosLocalDB.getCloudFetchCacheAge.mock.calls[1]).toEqual(['device-1', 2023, 6]); + }); + + test('when isCancelled returns true, then fewer months are fetched than discovered', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const device = makeFolder('d1-uuid', 'device-1'); + const year = makeFolder('y-uuid', '2024'); + const m1 = makeFolder('m1', '06'); + const m2 = makeFolder('m2', '05'); + const m3 = makeFolder('m3', '04'); + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(null); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [device] } as never) + .mockResolvedValueOnce({ folders: [year] } as never) + .mockResolvedValueOnce({ folders: [m1, m2, m3] } as never) + .mockResolvedValue({ folders: [] } as never); + + await photoCloudBrowser.syncAllHistory({ isCancelled: () => true }); + + expect(mockPhotosLocalDB.getCloudFetchCacheAge).not.toHaveBeenCalled(); + expect(mockPhotosLocalDB.upsertCloudAsset).not.toHaveBeenCalled(); + }); + + test('when a discovered month is still within TTL, then it is skipped without listing day folders', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const device = makeFolder('d1-uuid', 'device-1'); + const year = makeFolder('y-uuid', '2024'); + const m1 = makeFolder('m1-uuid', '06'); + const m2 = makeFolder('m2-uuid', '05'); + const day = makeFolder('day-uuid', '15'); + const file = makeFile('file-uuid', 'photo.jpg'); + const fresh = Date.now() - 1000; + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValueOnce(fresh).mockResolvedValueOnce(null); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [device] } as never) + .mockResolvedValueOnce({ folders: [year] } as never) + .mockResolvedValueOnce({ folders: [m1, m2] } as never) + .mockResolvedValueOnce({ folders: [day] } as never); + mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never); + + await photoCloudBrowser.syncAllHistory({}); + + expect(mockPhotosLocalDB.upsertCloudAsset).toHaveBeenCalledTimes(1); + expect(mockFolderService.getFolderFolders).toHaveBeenCalledTimes(4); + }); + + test('when six months each have files, then onMonthFetched is invoked six times', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const device = makeFolder('d1-uuid', 'device-1'); + const year = makeFolder('y-uuid', '2024'); + const months = Array.from({ length: 6 }, (_, i) => makeFolder(`m${i}`, String(i + 1).padStart(2, '0'))); + const day = makeFolder('day-uuid', '15'); + const file = makeFile('file-uuid', 'photo.jpg'); + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(null); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [device] } as never) + .mockResolvedValueOnce({ folders: [year] } as never) + .mockResolvedValueOnce({ folders: months } as never) + .mockResolvedValue({ folders: [day] } as never); + mockFolderService.getFolderContentByUuid.mockResolvedValue({ files: [file] } as never); + + const onMonthFetched = jest.fn(); + await photoCloudBrowser.syncAllHistory({ onMonthFetched }); + + expect(onMonthFetched).toHaveBeenCalledTimes(6); + }); + + test('when a discovered month has no files, then onMonthFetched is not invoked for that month', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const device = makeFolder('d1-uuid', 'device-1'); + const year = makeFolder('y-uuid', '2024'); + const monthWithFiles = makeFolder('m1-uuid', '06'); + const monthEmpty = makeFolder('m2-uuid', '05'); + const day = makeFolder('day-uuid', '15'); + const file = makeFile('file-uuid', 'photo.jpg'); + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(null); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [device] } as never) + .mockResolvedValueOnce({ folders: [year] } as never) + .mockResolvedValueOnce({ folders: [monthWithFiles, monthEmpty] } as never) + .mockResolvedValueOnce({ folders: [day] } as never) + .mockResolvedValueOnce({ folders: [] } as never); + mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never); + + const onMonthFetched = jest.fn(); + await photoCloudBrowser.syncAllHistory({ onMonthFetched }); - fetchMonthSpy.mockRestore(); + expect(onMonthFetched).toHaveBeenCalledTimes(1); }); }); diff --git a/src/services/photos/PhotoCloudBrowser.ts b/src/services/photos/PhotoCloudBrowser.ts index f68e6d5e6..302988fff 100644 --- a/src/services/photos/PhotoCloudBrowser.ts +++ b/src/services/photos/PhotoCloudBrowser.ts @@ -43,24 +43,71 @@ class PhotoCloudBrowserService { year: number; month: number; onMonthFetched?: () => void; - }): Promise { + }): 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; + if (cacheAge !== null && Date.now() - cacheAge < CACHE_TTL_MS) return 0; const yearFolder = await this.findChildFolder(deviceFolderUuid, String(year)); - if (!yearFolder) return; + if (!yearFolder) return 0; const monthStr = String(month).padStart(2, '0'); const monthFolder = await this.findChildFolder(yearFolder.uuid, monthStr); - if (!monthFolder) return; + if (!monthFolder) return 0; + + return this.fetchMonthFromFolder({ + deviceId, + monthFolderUuid: monthFolder.uuid, + year, + month, + onMonthFetched, + }); + } + + async syncAllHistory(options: { onMonthFetched?: () => void; isCancelled?: () => boolean }): Promise { + const { onMonthFetched, isCancelled } = options; + const devices = await this.listDeviceFolders(); + if (devices.length === 0) return; + + const months = await this.discoverAvailableMonths(devices); + if (months.length === 0) return; + + const CONCURRENCY = 3; + let cursor = 0; + const worker = async (): Promise => { + while (cursor < months.length) { + if (isCancelled?.()) return; + const target = months[cursor++]; + await this.fetchMonthFromFolder({ + deviceId: target.deviceId, + monthFolderUuid: target.monthFolderUuid, + year: target.year, + month: target.month, + onMonthFetched, + }); + } + }; + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); + } + + private async fetchMonthFromFolder(params: { + deviceId: string; + monthFolderUuid: string; + year: number; + month: number; + onMonthFetched?: () => void; + }): Promise { + const { deviceId, monthFolderUuid, year, month, onMonthFetched } = params; + const cacheAge = await this.localDB.getCloudFetchCacheAge(deviceId, year, month); + if (cacheAge !== null && Date.now() - cacheAge < CACHE_TTL_MS) return 0; - const dayFolders = await this.listAllFolders(monthFolder.uuid); + const dayFolders = await this.listAllFolders(monthFolderUuid); const now = Date.now(); + let count = 0; 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) { @@ -80,31 +127,36 @@ class PhotoCloudBrowserService { thumbnailType: thumb?.type ?? null, discoveredAt: now, }); + count++; } } - onMonthFetched?.(); + if (count > 0) { + onMonthFetched?.(); + } + return count; } - 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; - while (month <= 0) { - month += 12; - year -= 1; - } - for (const device of devices) { - await this.fetchMonth({ deviceId: device.name, deviceFolderUuid: device.uuid, year, month, onMonthFetched }); + private async discoverAvailableMonths( + devices: { uuid: string; name: string }[], + ): Promise<{ deviceId: string; year: number; month: number; monthFolderUuid: string }[]> { + const result: { deviceId: string; year: number; month: number; monthFolderUuid: string }[] = []; + for (const device of devices) { + const yearFolders = await this.listAllFolders(device.uuid); + for (const yearFolder of yearFolders) { + const year = Number.parseInt(yearFolder.plainName ?? '', 10); + if (Number.isNaN(year)) continue; + + const monthFolders = await this.listAllFolders(yearFolder.uuid); + for (const monthFolder of monthFolders) { + const month = Number.parseInt(monthFolder.plainName ?? '', 10); + if (Number.isNaN(month) || month < 1 || month > 12) continue; + result.push({ deviceId: device.name, year, month, monthFolderUuid: monthFolder.uuid }); + } } } + result.sort((a, b) => b.year - a.year || b.month - a.month); + return result; } private async findChildFolder(parentUuid: string, name: string): Promise { diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts index 43e9ac7c4..baebca550 100644 --- a/src/store/slices/photos/index.spec.ts +++ b/src/store/slices/photos/index.spec.ts @@ -17,8 +17,8 @@ import photosReducer, { photosSlice, PhotosState, runBackupCycleThunk, - runCloudMetadataSyncThunk, runDiscoveryThunk, + runFullCloudHistorySyncThunk, setNetworkConditionThunk, } from './index'; @@ -60,7 +60,7 @@ jest.mock('src/services/photos/PhotoDeduplicator', () => ({ jest.mock('src/services/photos/PhotoCloudBrowser', () => ({ photoCloudBrowser: { listDeviceFolders: jest.fn().mockResolvedValue([]), - syncAllDevicesFromMonth: jest.fn().mockResolvedValue(undefined), + syncAllHistory: jest.fn().mockResolvedValue(undefined), }, })); @@ -114,7 +114,7 @@ describe('photos slice', () => { mockPhotosLocalDB.markSynced.mockResolvedValue(undefined); mockPhotosLocalDB.markError.mockResolvedValue(undefined); mockCloudBrowser.listDeviceFolders.mockResolvedValue([]); - mockCloudBrowser.syncAllDevicesFromMonth.mockResolvedValue(undefined); + mockCloudBrowser.syncAllHistory.mockResolvedValue(undefined); // Prevent checkPermissionRevocationThunk from overwriting permissionStatus with undefined mockPermissionService.getStatus.mockResolvedValue('granted'); }); @@ -144,6 +144,7 @@ describe('photos slice', () => { sessionTotalAssets: 0, sessionUploadedAssets: 0, cloudFetchRevision: 0, + isFetchingCloudHistory: false, }; mockAsyncStorage.getItem.mockResolvedValueOnce(JSON.stringify(saved)); @@ -415,62 +416,77 @@ describe('photos slice', () => { expect(store.getState().photos.syncStatus).toBe('synced'); }); - describe('runCloudMetadataSyncThunk', () => { - test('when backup is disabled, then the drive API is not called', async () => { + describe('runFullCloudHistorySyncThunk', () => { + test('when the thunk runs, then syncAllHistory is invoked once', async () => { const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true })); - await store.dispatch(runCloudMetadataSyncThunk()); + await store.dispatch(runFullCloudHistorySyncThunk()); - expect(mockCloudBrowser.listDeviceFolders).not.toHaveBeenCalled(); + expect(mockCloudBrowser.syncAllHistory).toHaveBeenCalledTimes(1); }); - test('when backup is enabled but the device id is not set, then the drive API is not called', async () => { + test('when syncAllHistory invokes onMonthFetched, then cloudFetchRevision increments for each call', async () => { + mockCloudBrowser.syncAllHistory.mockImplementationOnce(async ({ onMonthFetched }) => { + onMonthFetched?.(); + onMonthFetched?.(); + onMonthFetched?.(); + }); + const store = makeStore(); - store.dispatch(photosSlice.actions.setState({ enabled: true, deviceId: null })); + store.dispatch(photosSlice.actions.setState({ enabled: true })); + const before = store.getState().photos.cloudFetchRevision; - await store.dispatch(runCloudMetadataSyncThunk()); + await store.dispatch(runFullCloudHistorySyncThunk()); - expect(mockCloudBrowser.listDeviceFolders).not.toHaveBeenCalled(); + expect(store.getState().photos.cloudFetchRevision).toBe(before + 3); }); - test('when there are no device folders in drive, then syncing is skipped', async () => { + test('when backup is disabled, then isCancelled returns true', async () => { + let capturedIsCancelled: (() => boolean) | undefined; + mockCloudBrowser.syncAllHistory.mockImplementationOnce(async ({ isCancelled }) => { + capturedIsCancelled = isCancelled; + }); + const store = makeStore(); - store.dispatch(photosSlice.actions.setState({ enabled: true, deviceId: 'device-1' })); + store.dispatch(photosSlice.actions.setState({ enabled: false })); - await store.dispatch(runCloudMetadataSyncThunk()); + await store.dispatch(runFullCloudHistorySyncThunk()); - expect(mockCloudBrowser.syncAllDevicesFromMonth).not.toHaveBeenCalled(); + expect(capturedIsCancelled?.()).toBe(true); }); - 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); + test('when the thunk completes, then isFetchingCloudHistory returns to false', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true })); + + await store.dispatch(runFullCloudHistorySyncThunk()); + + expect(store.getState().photos.isFetchingCloudHistory).toBe(false); + }); + + test('when syncAllHistory throws, then isFetchingCloudHistory returns to false', async () => { + mockCloudBrowser.syncAllHistory.mockRejectedValueOnce(new Error('cloud sync failed')); const store = makeStore(); - store.dispatch(photosSlice.actions.setState({ enabled: true, deviceId: 'device-1' })); + store.dispatch(photosSlice.actions.setState({ enabled: true })); - await store.dispatch(runCloudMetadataSyncThunk()); + await store.dispatch(runFullCloudHistorySyncThunk()); - expect(mockCloudBrowser.syncAllDevicesFromMonth).toHaveBeenCalledWith({ - devices, - fromYear: expect.any(Number), - fromMonth: expect.any(Number), - monthsBack: 12, - onMonthFetched: expect.any(Function), - }); + expect(store.getState().photos.isFetchingCloudHistory).toBe(false); }); - test('when the cloud sync step fails during a backup cycle, then the cycle still completes without throwing', async () => { + test('when the backup cycle runs, then cloud history sync and discovery both run', async () => { + mockPhotoDeviceId.getOrCreate.mockResolvedValue('device-id'); + 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(); + mockPermissionService.getStatus.mockResolvedValue('granted'); + + await store.dispatch(runBackupCycleThunk()); + + expect(mockCloudBrowser.syncAllHistory).toHaveBeenCalled(); + expect(mockScanner.scanAll).toHaveBeenCalled(); }); }); }); diff --git a/src/store/slices/photos/index.ts b/src/store/slices/photos/index.ts index 723103410..2807a7600 100644 --- a/src/store/slices/photos/index.ts +++ b/src/store/slices/photos/index.ts @@ -17,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' | 'fetching-cloud' | 'synced' | 'paused' | 'error'; +export type PhotoSyncStatus = 'idle' | 'scanning' | 'uploading' | 'synced' | 'paused' | 'error'; export interface PhotosState { enabled: boolean; @@ -34,6 +34,7 @@ export interface PhotosState { sessionTotalAssets: number; sessionUploadedAssets: number; cloudFetchRevision: number; + isFetchingCloudHistory: boolean; } const initialState: PhotosState = { @@ -51,6 +52,7 @@ const initialState: PhotosState = { sessionTotalAssets: 0, sessionUploadedAssets: 0, cloudFetchRevision: 0, + isFetchingCloudHistory: false, }; const persistPhotosSettings = async (state: PhotosState): Promise => { @@ -222,24 +224,23 @@ export const runUploadThunk = createAsyncThunk }, ); -export const runCloudMetadataSyncThunk = createAsyncThunk( - 'photos/runCloudMetadataSync', +export const runFullCloudHistorySyncThunk = createAsyncThunk( + 'photos/runFullCloudHistorySync', async (_, { getState, dispatch }) => { - 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, - fromYear: now.getFullYear(), - fromMonth: now.getMonth() + 1, - monthsBack: 12, - onMonthFetched: () => dispatch(photosSlice.actions.incrementCloudFetchRevision()), - }); + logger.info('[CloudHistorySync] Starting full cloud history sync'); + dispatch(photosSlice.actions.setIsFetchingCloudHistory(true)); + try { + await photosLocalDB.init(); + await photoCloudBrowser.syncAllHistory({ + onMonthFetched: () => dispatch(photosSlice.actions.incrementCloudFetchRevision()), + isCancelled: () => !getState().photos.enabled, + }); + logger.info('[CloudHistorySync] Full history sync complete'); + } catch (error) { + logger.error('[CloudHistorySync] Error during full cloud history sync', { error }); + } finally { + dispatch(photosSlice.actions.setIsFetchingCloudHistory(false)); + } }, ); @@ -247,7 +248,7 @@ export const runBackupCycleThunk = createAsyncThunk { const { syncStatus } = getState().photos; - if (syncStatus === 'scanning' || syncStatus === 'uploading' || syncStatus === 'fetching-cloud') { + if (syncStatus === 'scanning' || syncStatus === 'uploading') { return; } @@ -261,17 +262,7 @@ export const runBackupCycleThunk = createAsyncThunk { state.cloudFetchRevision += 1; }, + setIsFetchingCloudHistory: (state, action: PayloadAction) => { + state.isFetchingCloudHistory = action.payload; + }, }, });