diff --git a/src/services/common/network/upload/upload.service.ts b/src/services/common/network/upload/upload.service.ts index 0f73881dd..3660936fc 100644 --- a/src/services/common/network/upload/upload.service.ts +++ b/src/services/common/network/upload/upload.service.ts @@ -2,9 +2,11 @@ import { getEnvironmentConfigFromUser } from '../../../../lib/network'; import network from '../../../../network'; import { + CheckDuplicatedFilesResponse, CreateThumbnailEntryPayload, DriveFileData, FileEntryByUuid, + FileStructure, ReplaceFile, Thumbnail, } from '@internxt/sdk/dist/drive/storage/types'; @@ -76,6 +78,13 @@ class UploadService { return this.sdk.storageV2.createThumbnailEntryWithUUID(entry); } + public async checkFileExistence( + parentFolderUuid: string, + filesList: FileStructure[], + ): Promise { + return this.sdk.storageV2.checkDuplicatedFiles({ folderUuid: parentFolderUuid, filesList }); + } + public getFinalUri(fileUri: string, fileType: FileType): string { return fileType === 'document' ? decodeURIComponent(fileUri) : fileUri; } diff --git a/src/services/photos/PhotoBackupFolders.ts b/src/services/photos/PhotoBackupFolders.ts index f60b0858c..89ee7d34a 100644 --- a/src/services/photos/PhotoBackupFolders.ts +++ b/src/services/photos/PhotoBackupFolders.ts @@ -6,10 +6,10 @@ const PHOTOS_BACKUP_ROOT_NAME = 'Photos Backup'; class PhotoBackupFolderService { private photosRootUuid: string | null = null; - private deviceFolderUuid = new Map(); - private yearFolderUuid = new Map(); - private monthFolderUuid = new Map(); - private dayFolderUuid = new Map(); + private readonly deviceFolderUuid = new Map(); + private readonly yearFolderUuid = new Map(); + private readonly monthFolderUuid = new Map(); + private readonly dayFolderUuid = new Map(); async getOrCreateFolderForDate(deviceId: string, date: Date): Promise { const year = date.getFullYear().toString(); diff --git a/src/services/photos/PhotoUploadService.spec.ts b/src/services/photos/PhotoUploadService.spec.ts new file mode 100644 index 000000000..2a1217540 --- /dev/null +++ b/src/services/photos/PhotoUploadService.spec.ts @@ -0,0 +1,232 @@ +import * as RNFS from '@dr.pogodin/react-native-fs'; +import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; +import * as MediaLibrary from 'expo-media-library'; +import { uploadFile } from 'src/network/upload'; +import asyncStorageService from 'src/services/AsyncStorageService'; +import { isThumbnailSupported } from 'src/services/common/media/thumbnail.constants'; +import { generateThumbnail } from 'src/services/common/media/thumbnail.generation'; +import { uploadService } from 'src/services/common/network/upload/upload.service'; +import { PhotoUploadService } from './PhotoUploadService'; +import { photoBackupFolders } from './PhotoBackupFolders'; + +jest.mock('expo-media-library', () => ({ + getAssetInfoAsync: jest.fn(), + MediaType: { photo: 'photo', video: 'video', audio: 'audio', unknown: 'unknown' }, +})); + +jest.mock('@dr.pogodin/react-native-fs', () => ({ + stat: jest.fn(), + unlink: jest.fn(), + copyFile: jest.fn(), + CachesDirectoryPath: '/cache', +})); + +jest.mock('src/network/upload', () => ({ + uploadFile: jest.fn(), +})); + +jest.mock('src/services/AsyncStorageService', () => ({ + __esModule: true, + default: { getUser: jest.fn() }, +})); + +jest.mock('src/lib/network', () => ({ + getEnvironmentConfigFromUser: jest.fn().mockReturnValue({ + bucketId: 'bucket-id', + encryptionKey: 'mnemonic', + bridgeUser: 'bridge-user', + bridgePass: 'bridge-pass', + }), +})); + +jest.mock('src/services/AppService', () => ({ + constants: { BRIDGE_URL: 'https://bridge.example.com' }, +})); + +jest.mock('src/services/common/media/thumbnail.constants', () => ({ + isThumbnailSupported: jest.fn(), +})); + +jest.mock('src/services/common/media/thumbnail.generation', () => ({ + generateThumbnail: jest.fn(), +})); + +jest.mock('src/services/common/network/upload/upload.service', () => ({ + uploadService: { + checkFileExistence: jest.fn(), + createFileEntry: jest.fn(), + replaceFileEntry: jest.fn(), + createThumbnailEntry: jest.fn(), + }, +})); + +jest.mock('./PhotoBackupFolders', () => ({ + photoBackupFolders: { + getOrCreateFolderForDate: jest.fn(), + }, +})); + +const mockGetAssetInfoAsync = MediaLibrary.getAssetInfoAsync as jest.Mock; +const mockRnfsStat = RNFS.stat as jest.Mock; +const mockRnfsUnlink = RNFS.unlink as jest.Mock; +const mockUploadFile = uploadFile as jest.Mock; +const mockGetUser = asyncStorageService.getUser as jest.Mock; +const mockGenerateThumbnail = generateThumbnail as jest.Mock; +const mockIsThumbnailSupported = jest.mocked(isThumbnailSupported); +const mockCheckFileExistence = uploadService.checkFileExistence as jest.Mock; +const mockCreateFileEntry = uploadService.createFileEntry as jest.Mock; +const mockReplaceFileEntry = uploadService.replaceFileEntry as jest.Mock; +const mockCreateThumbnailEntry = uploadService.createThumbnailEntry as jest.Mock; +const mockGetOrCreateFolder = photoBackupFolders.getOrCreateFolderForDate as jest.Mock; + +const DEVICE_ID = 'device-123'; +const LOCAL_PATH = '/var/mobile/Media/DCIM/photo.jpg'; +const LOCAL_URI = `file://${LOCAL_PATH}`; + +const makeAsset = (overrides?: Partial): MediaLibrary.Asset => + ({ + id: 'asset-1', + filename: 'photo.jpg', + uri: LOCAL_URI, + mediaType: MediaLibrary.MediaType.photo, + creationTime: new Date('2024-06-15T10:00:00Z').getTime(), + modificationTime: new Date('2024-06-15T12:00:00Z').getTime(), + duration: 0, + width: 1920, + height: 1080, + ...overrides, + }) as MediaLibrary.Asset; + +beforeEach(() => { + jest.clearAllMocks(); + + mockGetAssetInfoAsync.mockResolvedValue({ localUri: LOCAL_URI }); + mockRnfsStat.mockResolvedValue({ size: 2_000_000 }); + mockRnfsUnlink.mockResolvedValue(undefined); + mockGetUser.mockResolvedValue({ bucket: 'bucket-id', mnemonic: 'mnemonic' }); + mockGetOrCreateFolder.mockResolvedValue('folder-uuid'); + + mockUploadFile.mockResolvedValueOnce('bucket-file-id').mockResolvedValueOnce('thumb-bucket-file-id'); + + mockCheckFileExistence.mockResolvedValue({ existentFiles: [] }); + mockCreateFileEntry.mockResolvedValue({ uuid: 'drive-file-uuid' }); + mockReplaceFileEntry.mockResolvedValue(undefined); + + mockIsThumbnailSupported.mockReturnValue(true); + mockGenerateThumbnail.mockResolvedValue({ + path: '/tmp/thumb.jpg', + width: 512, + height: 288, + size: 40_000, + type: 'JPEG', + }); + mockCreateThumbnailEntry.mockResolvedValue({}); +}); + +describe('PhotoUploadService.upload', () => { + test('when uploading a supported image, then a thumbnail is generated and registered with the drive file uuid', async () => { + const asset = makeAsset(); + + await PhotoUploadService.upload(asset, DEVICE_ID); + + expect(mockGenerateThumbnail).toHaveBeenCalledWith(LOCAL_PATH, 'jpg'); + expect(mockCreateThumbnailEntry).toHaveBeenCalledWith({ + fileUuid: 'drive-file-uuid', + type: 'JPEG', + size: 40_000, + maxWidth: 512, + maxHeight: 288, + bucketId: 'bucket-id', + bucketFile: 'thumb-bucket-file-id', + encryptVersion: EncryptionVersion.Aes03, + }); + }); + + test('when uploading a supported image, then the drive file uuid is returned', async () => { + const result = await PhotoUploadService.upload(makeAsset(), DEVICE_ID); + expect(result).toBe('drive-file-uuid'); + }); + + test('when the photo already exists in Drive, then its existing uuid is returned without uploading again', async () => { + mockCheckFileExistence.mockResolvedValue({ existentFiles: [{ uuid: 'existing-uuid' }] }); + + const result = await PhotoUploadService.upload(makeAsset(), DEVICE_ID); + + expect(result).toBe('existing-uuid'); + expect(mockUploadFile).not.toHaveBeenCalled(); + expect(mockCreateFileEntry).not.toHaveBeenCalled(); + }); + + test('when the main file upload fails, then the error is propagated to the caller', async () => { + mockUploadFile.mockReset().mockRejectedValueOnce(new Error('network timeout')); + + await expect(PhotoUploadService.upload(makeAsset(), DEVICE_ID)).rejects.toThrow('network timeout'); + expect(mockCreateFileEntry).not.toHaveBeenCalled(); + }); + + test('when the asset extension is not supported for thumbnails, then no thumbnail is created and the upload still succeeds', async () => { + mockIsThumbnailSupported.mockReturnValue(false); + mockUploadFile.mockReset().mockResolvedValueOnce('bucket-file-id'); + + const result = await PhotoUploadService.upload(makeAsset({ filename: 'photo.dng' }), DEVICE_ID); + + expect(mockGenerateThumbnail).not.toHaveBeenCalled(); + expect(mockCreateThumbnailEntry).not.toHaveBeenCalled(); + expect(result).toBe('drive-file-uuid'); + }); + + test('when thumbnail generation throws, then the upload still returns the drive file uuid', async () => { + mockGenerateThumbnail.mockRejectedValue(new Error('OOM')); + // Only one uploadFile call for the main file + mockUploadFile.mockReset().mockResolvedValueOnce('bucket-file-id'); + + const result = await PhotoUploadService.upload(makeAsset(), DEVICE_ID); + + expect(result).toBe('drive-file-uuid'); + expect(mockCreateThumbnailEntry).not.toHaveBeenCalled(); + }); + + test('when the thumbnail bucket upload throws, then the main upload still returns the drive file uuid', async () => { + mockUploadFile.mockReset().mockResolvedValueOnce('bucket-file-id').mockRejectedValueOnce(new Error('network error')); + + const result = await PhotoUploadService.upload(makeAsset(), DEVICE_ID); + + expect(result).toBe('drive-file-uuid'); + expect(mockCreateThumbnailEntry).not.toHaveBeenCalled(); + }); + + test('when the thumbnail bucket upload throws, then the thumbnail temp file is still cleaned up', async () => { + mockUploadFile.mockReset().mockResolvedValueOnce('bucket-file-id').mockRejectedValueOnce(new Error('network error')); + + await PhotoUploadService.upload(makeAsset(), DEVICE_ID); + + expect(mockRnfsUnlink).toHaveBeenCalledWith('/tmp/thumb.jpg'); + }); +}); + +describe('PhotoUploadService.replace', () => { + test('when replacing an asset, then a thumbnail is regenerated and registered against the existing file uuid', async () => { + const asset = makeAsset(); + + await PhotoUploadService.replace(asset, 'existing-remote-id', DEVICE_ID); + + expect(mockGenerateThumbnail).toHaveBeenCalledWith(LOCAL_PATH, 'jpg'); + expect(mockCreateThumbnailEntry).toHaveBeenCalledWith( + expect.objectContaining({ fileUuid: 'existing-remote-id' }), + ); + }); + + test('when replacing an asset, then the existing remote file id is returned', async () => { + const result = await PhotoUploadService.replace(makeAsset(), 'existing-remote-id', DEVICE_ID); + expect(result).toBe('existing-remote-id'); + }); + + test('when thumbnail generation fails during a replace, then the replace still returns the existing remote file id', async () => { + mockGenerateThumbnail.mockRejectedValue(new Error('codec error')); + mockUploadFile.mockReset().mockResolvedValueOnce('bucket-file-id'); + + const result = await PhotoUploadService.replace(makeAsset(), 'existing-remote-id', DEVICE_ID); + + expect(result).toBe('existing-remote-id'); + }); +}); diff --git a/src/services/photos/PhotoUploadService.ts b/src/services/photos/PhotoUploadService.ts index d0158a183..741a3acb0 100644 --- a/src/services/photos/PhotoUploadService.ts +++ b/src/services/photos/PhotoUploadService.ts @@ -6,7 +6,10 @@ import { getEnvironmentConfigFromUser } from 'src/lib/network'; import { uploadFile } from 'src/network/upload'; import { constants } from 'src/services/AppService'; import asyncStorageService from 'src/services/AsyncStorageService'; +import { isThumbnailSupported } from 'src/services/common/media/thumbnail.constants'; +import { generateThumbnail } from 'src/services/common/media/thumbnail.generation'; import { uploadService } from 'src/services/common/network/upload/upload.service'; +import { FileAlreadyExistsError } from './errors'; import { photoBackupFolders } from './PhotoBackupFolders'; import { ANDROID_CONTENT_URI_SCHEME, @@ -19,8 +22,15 @@ import { const TEMP_FILE_PREFIX = 'photo_upload_'; -interface BucketUploadResult { - bucketFileId: string; +interface UploadCredentials { + bucketId: string; + encryptionKey: string; + bridgeUser: string; + bridgePass: string; +} + +interface FileUploadResult { + fileId: string; bucketId: string; fileSize: number; plainName: string; @@ -28,6 +38,9 @@ interface BucketUploadResult { modificationIso: string; creationIso: string; folderUuid: string; + localFilePath: string; + tempPath?: string; + credentials: UploadCredentials; } const resolveLocalPath = async (asset: MediaLibrary.Asset): Promise<{ localPath: string; tempPath?: string }> => { @@ -55,7 +68,7 @@ const uploadAssetToBucket = async ( asset: MediaLibrary.Asset, deviceId: string, onProgress?: (ratio: number) => void, -): Promise => { +): Promise => { const { localPath: localFilePath, tempPath } = await resolveLocalPath(asset); const createdDate = new Date(asset.creationTime); @@ -69,10 +82,17 @@ const uploadAssetToBucket = async ( photoBackupFolders.getOrCreateFolderForDate(deviceId, createdDate), ]); const { bucketId, encryptionKey, bridgeUser, bridgePass } = getEnvironmentConfigFromUser(user); + const { plainName, fileExtension } = splitFileNameAndExtension(fileName); - let bucketFileId: string; + const { existentFiles } = await uploadService.checkFileExistence(folderUuid, [{ plainName, type: fileExtension }]); + if (existentFiles.length > 0) { + await cleanupTempFile(tempPath); + throw new FileAlreadyExistsError(`${plainName}.${fileExtension}`, existentFiles[0].uuid); + } + + let fileId: string; try { - bucketFileId = await uploadFile( + fileId = await uploadFile( localFilePath, bucketId, encryptionKey, @@ -81,16 +101,13 @@ const uploadAssetToBucket = async ( { notifyProgress: onProgress }, ); } catch (uploadError) { - const msg = uploadError instanceof Error ? uploadError.message : String(uploadError); - throw new Error(`Bucket upload failed for ${fileName}: ${msg}`); - } finally { - if (tempPath) await RNFS.unlink(tempPath).catch(() => null); + await cleanupTempFile(tempPath); + const message = uploadError instanceof Error ? uploadError.message : String(uploadError); + throw new Error(`Bucket upload failed for ${fileName}: ${message}`); } - const { plainName, fileExtension } = splitFileNameAndExtension(fileName); - return { - bucketFileId, + fileId, bucketId, fileSize: fileStat.size, plainName, @@ -98,27 +115,101 @@ const uploadAssetToBucket = async ( modificationIso, creationIso, folderUuid, + localFilePath, + tempPath, + credentials: { bucketId, encryptionKey, bridgeUser, bridgePass }, }; }; +const cleanupTempFile = async (tempPath?: string): Promise => { + if (!tempPath) return; + await RNFS.unlink(tempPath).catch(() => null); +}; + +const uploadThumbnailForAsset = async ( + localFilePath: string, + fileExtension: string, + fileUuid: string, + credentials: UploadCredentials, +): Promise => { + if (!isThumbnailSupported(fileExtension)) return; + + let thumbnailPath: string | undefined; + try { + const thumbnail = await generateThumbnail(localFilePath, fileExtension); + thumbnailPath = thumbnail.path; + + const thumbnailFileId = await uploadFile( + thumbnail.path, + credentials.bucketId, + credentials.encryptionKey, + constants.BRIDGE_URL, + { user: credentials.bridgeUser, pass: credentials.bridgePass }, + {}, + ); + + await uploadService.createThumbnailEntry({ + fileUuid, + type: thumbnail.type, + size: thumbnail.size, + maxWidth: thumbnail.width, + maxHeight: thumbnail.height, + bucketId: credentials.bucketId, + bucketFile: thumbnailFileId, + encryptVersion: EncryptionVersion.Aes03, + }); + } catch { + // Thumbnail is best-effort — never block the main upload result + } finally { + await cleanupTempFile(thumbnailPath); + } +}; + export const PhotoUploadService = { async upload(asset: MediaLibrary.Asset, deviceId: string, onProgress?: (ratio: number) => void): Promise { - const { bucketFileId, bucketId, fileSize, plainName, fileExtension, modificationIso, creationIso, folderUuid } = - await uploadAssetToBucket(asset, deviceId, onProgress); + let fileUploadResult: FileUploadResult; + try { + fileUploadResult = await uploadAssetToBucket(asset, deviceId, onProgress); + } catch (err) { + if (err instanceof FileAlreadyExistsError) { + return err.existingUuid; + } + throw err; + } - const driveFile = await uploadService.createFileEntry({ - fileId: bucketFileId, - type: fileExtension, - size: fileSize, + const { + fileId, + bucketId, + fileSize, plainName, - bucket: bucketId, + fileExtension, + modificationIso, + creationIso, folderUuid, - encryptVersion: EncryptionVersion.Aes03, - modificationTime: modificationIso, - creationTime: creationIso, - }); - - return driveFile.uuid; + localFilePath, + tempPath, + credentials, + } = fileUploadResult; + + try { + const driveFile = await uploadService.createFileEntry({ + fileId, + type: fileExtension, + size: fileSize, + plainName, + bucket: bucketId, + folderUuid, + encryptVersion: EncryptionVersion.Aes03, + modificationTime: modificationIso, + creationTime: creationIso, + }); + + await uploadThumbnailForAsset(localFilePath, fileExtension, driveFile.uuid, credentials); + + return driveFile.uuid; + } finally { + await cleanupTempFile(tempPath); + } }, async replace( @@ -127,10 +218,20 @@ export const PhotoUploadService = { deviceId: string, onProgress?: (ratio: number) => void, ): Promise { - const { bucketFileId, fileSize } = await uploadAssetToBucket(asset, deviceId, onProgress); + const { fileId, fileSize, localFilePath, fileExtension, tempPath, credentials } = await uploadAssetToBucket( + asset, + deviceId, + onProgress, + ); - await uploadService.replaceFileEntry(existingRemoteFileId, { fileId: bucketFileId, size: fileSize }); + try { + await uploadService.replaceFileEntry(existingRemoteFileId, { fileId, size: fileSize }); - return existingRemoteFileId; + await uploadThumbnailForAsset(localFilePath, fileExtension, existingRemoteFileId, credentials); + + return existingRemoteFileId; + } finally { + await cleanupTempFile(tempPath); + } }, }; diff --git a/src/services/photos/errors.ts b/src/services/photos/errors.ts new file mode 100644 index 000000000..a0ea1565e --- /dev/null +++ b/src/services/photos/errors.ts @@ -0,0 +1,9 @@ +export class FileAlreadyExistsError extends Error { + constructor( + fileName: string, + public readonly existingUuid: string, + ) { + super(`File already exists in Drive: ${fileName}`); + this.name = 'FileAlreadyExistsError'; + } +} diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts index ec280aa6f..916bb41e7 100644 --- a/src/store/slices/photos/index.spec.ts +++ b/src/store/slices/photos/index.spec.ts @@ -17,7 +17,6 @@ import photosReducer, { PhotosState, runBackupCycleThunk, runDiscoveryThunk, - runUploadThunk, setNetworkConditionThunk, } from './index'; @@ -281,8 +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('granted'); // consumed by background runBackupCycleThunk - mockPermissionService.getStatus.mockResolvedValueOnce('limited'); // consumed by the direct checkPermissionRevocationThunk call + mockPermissionService.getStatus.mockResolvedValueOnce('granted'); + mockPermissionService.getStatus.mockResolvedValueOnce('limited'); const store = makeStore(); await store.dispatch(enableBackupThunk());