diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts index 175d02db8..1dfc68bd1 100644 --- a/src/store/slices/photos/index.spec.ts +++ b/src/store/slices/photos/index.spec.ts @@ -4,6 +4,7 @@ import asyncStorageService from 'src/services/AsyncStorageService'; import { PhotoAssetScanner } from 'src/services/photos/PhotoAssetScanner'; import { PhotoDeduplicator } from 'src/services/photos/PhotoDeduplicator'; import { PhotoDeviceId } from 'src/services/photos/PhotoDeviceId'; +import { PhotoUploadQueue } from 'src/services/photos/PhotoUploadQueue'; import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; import { AppDispatch } from 'src/store'; import photosReducer, { @@ -40,15 +41,29 @@ jest.mock('src/services/photos/PhotoDeviceId', () => ({ })); jest.mock('src/services/photos/PhotoAssetScanner', () => ({ - PhotoAssetScanner: { scanAll: jest.fn().mockResolvedValue([]) }, + PhotoAssetScanner: { + scanAll: jest.fn().mockResolvedValue([]), + getAssetsByIds: jest.fn().mockResolvedValue([]), + }, +})); + +jest.mock('src/services/photos/PhotoUploadQueue', () => ({ + PhotoUploadQueue: { start: jest.fn().mockResolvedValue(undefined) }, })); jest.mock('src/services/photos/PhotoDeduplicator', () => ({ - PhotoDeduplicator: { getAssetsToSync: jest.fn().mockResolvedValue([]) }, + PhotoDeduplicator: { getAssetsToSync: jest.fn().mockResolvedValue({ newAssets: [], editedAssets: [] }) }, })); jest.mock('src/services/photos/database/photosLocalDB', () => ({ - photosLocalDB: { init: jest.fn().mockResolvedValue(undefined) }, + photosLocalDB: { + init: jest.fn().mockResolvedValue(undefined), + getPendingAssets: jest.fn().mockResolvedValue([]), + markPending: jest.fn().mockResolvedValue(undefined), + markPendingEdit: jest.fn().mockResolvedValue(undefined), + markSynced: jest.fn().mockResolvedValue(undefined), + markError: jest.fn().mockResolvedValue(undefined), + }, })); const mockAsyncStorage = asyncStorageService as jest.Mocked; @@ -57,6 +72,7 @@ const mockPhotoDeviceId = PhotoDeviceId as jest.Mocked; const mockScanner = PhotoAssetScanner as jest.Mocked; const mockDeduplicator = PhotoDeduplicator as jest.Mocked; const mockPhotosLocalDB = photosLocalDB as jest.Mocked; +const mockUploadQueue = PhotoUploadQueue as jest.Mocked; const makeStore = () => { const store = configureStore({ reducer: { photos: photosReducer } }); @@ -72,7 +88,23 @@ const getPersistedState = (): PhotosState => { describe('photos slice', () => { beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); + // Re-set default implementations after reset clears them + mockAsyncStorage.saveItem.mockResolvedValue(undefined); + mockAsyncStorage.getItem.mockResolvedValue(null); + mockPhotoDeviceId.getOrCreate.mockResolvedValue('mock-device-id'); + mockScanner.scanAll.mockResolvedValue([]); + mockScanner.getAssetsByIds.mockResolvedValue([]); + mockUploadQueue.start.mockResolvedValue(undefined); + mockDeduplicator.getAssetsToSync.mockResolvedValue({ newAssets: [], editedAssets: [] }); + mockPhotosLocalDB.init.mockResolvedValue(undefined); + mockPhotosLocalDB.getPendingAssets.mockResolvedValue([]); + mockPhotosLocalDB.markPending.mockResolvedValue(undefined); + mockPhotosLocalDB.markPendingEdit.mockResolvedValue(undefined); + mockPhotosLocalDB.markSynced.mockResolvedValue(undefined); + mockPhotosLocalDB.markError.mockResolvedValue(undefined); + // Prevent checkPermissionRevocationThunk from overwriting permissionStatus with undefined + mockPermissionService.getStatus.mockResolvedValue('granted'); }); test('when the app starts for the first time, then backup is disabled and set to wifi-only with no permission yet', () => { @@ -90,9 +122,15 @@ describe('photos slice', () => { networkCondition: 'wifi-and-data', permissionStatus: 'granted', syncStatus: 'idle', - pendingCount: 0, - totalScannedCount: 0, + pendingBackupAssets: 0, + totalScannedAssets: 0, + totalAssetsUploaded: 0, + currentUploadProgress: 0, + lastSyncTimestamp: null, + uploadingAssetIds: [], deviceId: null, + sessionTotalAssets: 0, + sessionUploadedAssets: 0, }; mockAsyncStorage.getItem.mockResolvedValueOnce(JSON.stringify(saved)); @@ -150,6 +188,7 @@ describe('photos slice', () => { test('when the user enables backup and grants limited permission, then backup is turned on and saved', async () => { mockPermissionService.requestPermission.mockResolvedValueOnce('limited'); + mockPermissionService.getStatus.mockResolvedValue('limited'); const store = makeStore(); const result = await store.dispatch(enableBackupThunk()).unwrap(); @@ -241,7 +280,8 @@ describe('photos slice', () => { test('when the permission check runs and the user has limited access, then backup continues running', async () => { mockPermissionService.requestPermission.mockResolvedValueOnce('granted'); - mockPermissionService.getStatus.mockResolvedValueOnce('limited'); + mockPermissionService.getStatus.mockResolvedValueOnce('granted'); // consumed by background runBackupCycleThunk + mockPermissionService.getStatus.mockResolvedValueOnce('limited'); // consumed by the direct checkPermissionRevocationThunk call const store = makeStore(); await store.dispatch(enableBackupThunk()); @@ -293,18 +333,18 @@ describe('photos slice', () => { mockPermissionService.requestPermission.mockResolvedValueOnce('granted'); const assets = Array.from({ length: 10 }, (_, i) => ({ id: `asset-${i}` })); mockScanner.scanAll.mockResolvedValueOnce(assets as never); - mockDeduplicator.getAssetsToSync.mockResolvedValueOnce(assets.slice(3) as never); + mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: assets.slice(3), editedAssets: [] } as never); const store = makeStore(); await store.dispatch(enableBackupThunk()); jest.clearAllMocks(); mockScanner.scanAll.mockResolvedValueOnce(assets as never); - mockDeduplicator.getAssetsToSync.mockResolvedValueOnce(assets.slice(3) as never); + mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: assets.slice(3), editedAssets: [] } as never); mockPhotosLocalDB.init.mockResolvedValueOnce(undefined); await store.dispatch(runDiscoveryThunk()); - expect(store.getState().photos.pendingCount).toBe(7); - expect(store.getState().photos.totalScannedCount).toBe(10); + expect(store.getState().photos.pendingBackupAssets).toBe(7); + expect(store.getState().photos.totalScannedAssets).toBe(10); expect(store.getState().photos.syncStatus).toBe('idle'); }); @@ -315,12 +355,12 @@ describe('photos slice', () => { await store.dispatch(enableBackupThunk()); jest.clearAllMocks(); mockScanner.scanAll.mockResolvedValueOnce([] as never); - mockDeduplicator.getAssetsToSync.mockResolvedValueOnce([] as never); + mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: [], editedAssets: [] } as never); mockPhotosLocalDB.init.mockResolvedValueOnce(undefined); await store.dispatch(runDiscoveryThunk()); - expect(store.getState().photos.pendingCount).toBe(0); - expect(store.getState().photos.totalScannedCount).toBe(0); + expect(store.getState().photos.pendingBackupAssets).toBe(0); + expect(store.getState().photos.totalScannedAssets).toBe(0); expect(store.getState().photos.syncStatus).toBe('idle'); }); @@ -343,23 +383,22 @@ describe('photos slice', () => { expect(store.getState().photos.enabled).toBe(false); }); - test('when a backup cycle starts and all conditions are met, then photos are scanned and the pending count is updated', async () => { + test('when a backup cycle starts and all conditions are met, then photos are scanned, the pending count is updated and the cycle completes', async () => { const store = makeStore(); store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); mockPermissionService.getStatus.mockResolvedValueOnce('granted'); mockPhotoDeviceId.getOrCreate.mockResolvedValueOnce('device-id'); const assets = Array.from({ length: 5 }, (_, i) => ({ id: `asset-${i}` })); mockScanner.scanAll.mockResolvedValueOnce(assets as never); - mockDeduplicator.getAssetsToSync.mockResolvedValueOnce(assets as never); + mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ newAssets: assets, editedAssets: [] } as never); mockPhotosLocalDB.init.mockResolvedValueOnce(undefined); await store.dispatch(runBackupCycleThunk()); - // drain any background microtasks before asserting await Promise.resolve(); expect(mockPhotoDeviceId.getOrCreate).toHaveBeenCalledTimes(1); expect(mockScanner.scanAll).toHaveBeenCalledTimes(1); - expect(store.getState().photos.pendingCount).toBe(5); - expect(store.getState().photos.syncStatus).toBe('idle'); + expect(store.getState().photos.pendingBackupAssets).toBe(5); + expect(store.getState().photos.syncStatus).toBe('synced'); }); }); diff --git a/src/store/slices/photos/index.ts b/src/store/slices/photos/index.ts index ffb250882..db8dd1f9d 100644 --- a/src/store/slices/photos/index.ts +++ b/src/store/slices/photos/index.ts @@ -3,6 +3,7 @@ import asyncStorageService from 'src/services/AsyncStorageService'; import { PhotoAssetScanner } from 'src/services/photos/PhotoAssetScanner'; import { PhotoDeduplicator } from 'src/services/photos/PhotoDeduplicator'; import { PhotoDeviceId } from 'src/services/photos/PhotoDeviceId'; +import { PhotoUploadQueue } from 'src/services/photos/PhotoUploadQueue'; import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; import { isPermissionActive, @@ -14,16 +15,22 @@ import { logger } from '../../../services/common'; import { RootState } from '../../index'; export type PhotoNetworkCondition = 'wifi-only' | 'wifi-and-data'; -export type PhotoSyncStatus = 'idle' | 'scanning' | 'synced' | 'error'; +export type PhotoSyncStatus = 'idle' | 'scanning' | 'uploading' | 'synced' | 'paused' | 'error'; export interface PhotosState { enabled: boolean; networkCondition: PhotoNetworkCondition; permissionStatus: PhotoPermissionStatus; syncStatus: PhotoSyncStatus; - pendingCount: number; - totalScannedCount: number; + pendingBackupAssets: number; + totalScannedAssets: number; + totalAssetsUploaded: number; + currentUploadProgress: number; + lastSyncTimestamp: number | null; + uploadingAssetIds: string[]; deviceId: string | null; + sessionTotalAssets: number; + sessionUploadedAssets: number; } const initialState: PhotosState = { @@ -31,9 +38,15 @@ const initialState: PhotosState = { networkCondition: 'wifi-only', permissionStatus: 'undetermined', syncStatus: 'idle', - pendingCount: 0, - totalScannedCount: 0, + pendingBackupAssets: 0, + totalScannedAssets: 0, + totalAssetsUploaded: 0, + currentUploadProgress: 0, + lastSyncTimestamp: null, + uploadingAssetIds: [], deviceId: null, + sessionTotalAssets: 0, + sessionUploadedAssets: 0, }; const persistPhotosSettings = async (state: PhotosState): Promise => { @@ -43,10 +56,11 @@ const persistPhotosSettings = async (state: PhotosState): Promise => { export const hydratePhotosStateThunk = createAsyncThunk( 'photos/setState', async (_, { dispatch }) => { - const raw = await asyncStorageService.getItem(AsyncStorageKey.PhotosSettings); - if (raw) { + await photosLocalDB.init(); + const persistedState = await asyncStorageService.getItem(AsyncStorageKey.PhotosSettings); + if (persistedState) { try { - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(persistedState) as Partial; dispatch(photosSlice.actions.setState(parsed)); } catch (error) { logger.error('Failed to parse photos settings from storage', { error }); @@ -70,7 +84,7 @@ export const enableBackupThunk = createAsyncThunk< if (isGranted) { await dispatch(initDeviceIdThunk()); - dispatch(runDiscoveryThunk()); + dispatch(runBackupCycleThunk()); } return { isGranted, permissionStatus }; @@ -121,15 +135,22 @@ export const runDiscoveryThunk = createAsyncThunk photosLocalDB.markPending(asset.id)), + ...editedAssets.map((asset) => photosLocalDB.markPendingEdit(asset.id)), + ]); + const pendingCount = newAssets.length + editedAssets.length; dispatch( photosSlice.actions.setDiscoveryResult({ - pendingCount: assetsToSync.length, - totalScannedCount: scannedAssets.length, + pendingBackupAssets: pendingCount, + totalScannedAssets: scannedAssets.length, }), ); dispatch(photosSlice.actions.setSyncStatus('idle')); - logger.info(`[Discovery] Complete — scanned: ${scannedAssets.length}, pending: ${assetsToSync.length}`); + logger.info( + `[Discovery] Complete — scanned: ${scannedAssets.length}, new: ${newAssets.length}, edited: ${editedAssets.length}`, + ); } catch (error) { logger.error('[Discovery] Failed', { error }); dispatch(photosSlice.actions.setSyncStatus('error')); @@ -137,15 +158,83 @@ export const runDiscoveryThunk = createAsyncThunk( + 'photos/runUpload', + async (_, { getState, dispatch }) => { + const { enabled, permissionStatus, deviceId } = getState().photos; + if (!enabled || !isPermissionActive(permissionStatus) || !deviceId) return; + + const localDBPendingAssets = await photosLocalDB.getPendingAssets(); + if (localDBPendingAssets.length === 0) { + dispatch(photosSlice.actions.setSyncStatus('synced')); + return; + } + + const pendingAssetIds = localDBPendingAssets.map((asset) => asset.assetId); + const resolvedAssets = await PhotoAssetScanner.getAssetsByIds(pendingAssetIds); + const assetById = new Map(resolvedAssets.map((a) => [a.id, a])); + + const uploadAssetJobs = localDBPendingAssets.flatMap((dbAsset) => { + const asset = assetById.get(dbAsset.assetId); + if (!asset) return []; + if (dbAsset.status === 'pending_edit') { + // TODO: this is temporary, maybe we should store the hash + // of the content in the servers to have a more reliable way to + // detect edits and avoid re-uploads of edited assets that haven't changed in content + if (!dbAsset.remoteFileId) + throw new Error( + `[Upload] Asset ${dbAsset.assetId} is pending_edit but has no remote_file_id — DB may be corrupted`, + ); + return [{ asset, existingRemoteFileId: dbAsset.remoteFileId }]; + } + return [{ asset }]; + }); + + dispatch(photosSlice.actions.setSyncStatus('uploading')); + dispatch(photosSlice.actions.setSessionUploadTotalAssets(uploadAssetJobs.length)); + + await PhotoUploadQueue.start(uploadAssetJobs, deviceId, { + onAssetStart: (assetId) => { + dispatch(photosSlice.actions.addUploadingAssetId(assetId)); + }, + onAssetProgress: (_, ratio) => { + dispatch(photosSlice.actions.setCurrentUploadProgress(ratio)); + }, + onAssetDone: async (assetId, remoteFileId, modificationTime) => { + await photosLocalDB.markSynced(assetId, remoteFileId, modificationTime); + dispatch(photosSlice.actions.removeUploadingAssetId(assetId)); + dispatch(photosSlice.actions.incrementTotalAssetsUploaded()); + dispatch(photosSlice.actions.incrementSessionUploadedAssets()); + }, + onAssetError: async (assetId, error) => { + logger.error(`[Upload] Asset ${assetId} failed: ${error?.message ?? String(error)}`); + await photosLocalDB.markError(assetId, error.message); + dispatch(photosSlice.actions.removeUploadingAssetId(assetId)); + }, + }); + + dispatch(photosSlice.actions.setSyncStatus('synced')); + dispatch(photosSlice.actions.setLastSyncTimestamp(Date.now())); + dispatch(photosSlice.actions.setCurrentUploadProgress(0)); + }, +); + export const runBackupCycleThunk = createAsyncThunk( 'photos/runBackupCycle', async (_, { getState, dispatch }) => { + const { syncStatus } = getState().photos; + if (syncStatus === 'scanning' || syncStatus === 'uploading') return; + await dispatch(checkPermissionRevocationThunk()); const { enabled, permissionStatus, deviceId } = getState().photos; if (!enabled || !isPermissionActive(permissionStatus)) return; if (!deviceId) await dispatch(initDeviceIdThunk()); await dispatch(runDiscoveryThunk()); + + if (getState().photos.pendingBackupAssets > 0) { + await dispatch(runUploadThunk()); + } }, ); @@ -183,9 +272,33 @@ export const photosSlice = createSlice({ setDeviceId: (state, action: PayloadAction) => { state.deviceId = action.payload; }, - setDiscoveryResult: (state, action: PayloadAction<{ pendingCount: number; totalScannedCount: number }>) => { - state.pendingCount = action.payload.pendingCount; - state.totalScannedCount = action.payload.totalScannedCount; + setDiscoveryResult: (state, action: PayloadAction<{ pendingBackupAssets: number; totalScannedAssets: number }>) => { + state.pendingBackupAssets = action.payload.pendingBackupAssets; + state.totalScannedAssets = action.payload.totalScannedAssets; + }, + addUploadingAssetId: (state, action: PayloadAction) => { + if (!state.uploadingAssetIds.includes(action.payload)) { + state.uploadingAssetIds.push(action.payload); + } + }, + removeUploadingAssetId: (state, action: PayloadAction) => { + state.uploadingAssetIds = state.uploadingAssetIds.filter((id) => id !== action.payload); + }, + setCurrentUploadProgress: (state, action: PayloadAction) => { + state.currentUploadProgress = action.payload; + }, + incrementTotalAssetsUploaded: (state) => { + state.totalAssetsUploaded += 1; + }, + setLastSyncTimestamp: (state, action: PayloadAction) => { + state.lastSyncTimestamp = action.payload; + }, + setSessionUploadTotalAssets: (state, action: PayloadAction) => { + state.sessionTotalAssets = action.payload; + state.sessionUploadedAssets = 0; + }, + incrementSessionUploadedAssets: (state) => { + state.sessionUploadedAssets += 1; }, }, });