From c0c4a4df4c7737293fc9e5adbc9fd1f67df50538 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 4 Jun 2026 17:34:24 +0200 Subject: [PATCH] Fix photos backup: video thumbnails, duplicate devices, cloud-only assets download and upload order --- .../react-native-create-thumbnail+2.2.0.patch | 58 ++++++++++++++----- .../PhotosScreen/components/PhotoItem.tsx | 43 +++++++++----- src/services/common/uri/uriHelpers.ts | 9 ++- .../photos/PhotoAssetFetchService.spec.ts | 16 ++++- src/services/photos/PhotoAssetFetchService.ts | 6 +- src/services/photos/PhotoCloudBrowser.spec.ts | 30 ++++++++-- src/services/photos/PhotoCloudBrowser.ts | 40 ++++++++++++- src/services/photos/PhotoDeviceId.spec.ts | 25 ++++++++ src/services/photos/PhotoDeviceId.ts | 26 +++++++-- .../photos/PhotoUploadService.spec.ts | 4 +- src/services/photos/PhotoUploadService.ts | 25 +++++--- src/services/photos/database/photosLocalDB.ts | 12 ++++ .../photos/database/tables/asset_sync.ts | 2 +- .../photos/database/tables/cloud_asset.ts | 2 + 14 files changed, 240 insertions(+), 58 deletions(-) diff --git a/patches/react-native-create-thumbnail+2.2.0.patch b/patches/react-native-create-thumbnail+2.2.0.patch index ff7c3ab15..4c2bca9aa 100644 --- a/patches/react-native-create-thumbnail+2.2.0.patch +++ b/patches/react-native-create-thumbnail+2.2.0.patch @@ -1,29 +1,33 @@ diff --git a/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m b/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m -index 4c87cfa..26da63e 100644 +index 4c87cfa..4764b8b 100644 --- a/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m +++ b/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m -@@ -1,4 +1,5 @@ +@@ -1,4 +1,6 @@ #import "CreateThumbnail.h" +#import - ++#import + @implementation CreateThumbnail - -@@ -44,8 +45,10 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi - + +@@ -44,8 +46,13 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi + if ([url_ hasPrefix:@"http://"] || [url_ hasPrefix:@"https://"]) { vidURL = [NSURL URLWithString:url]; + } else if ([url_ hasPrefix:@"file://"]) { ++ vidURL = [NSURL URLWithString:url]; ++ } else if ([url_ hasPrefix:@"ph://"]) { ++ // PHAsset URI — preserve as-is for AVURLAsset + vidURL = [NSURL URLWithString:url]; } else { - // Consider it's file url path + // Consider it's a raw file path without scheme vidURL = [NSURL fileURLWithPath:url]; } - -@@ -79,7 +82,18 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi + +@@ -79,9 +86,46 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi reject(error.domain, error.description, nil); }; - + - if ([url_ hasPrefix:@"http://"] || [url_ hasPrefix:@"https://"]) { + NSString *fileExt = [[url_ pathExtension] lowercaseString]; + BOOL isImageFile = [fileExt isEqualToString:@"jpg"] || @@ -33,17 +37,45 @@ index 4c87cfa..26da63e 100644 + [fileExt isEqualToString:@"heif"]; + + if (isImageFile) { -+ NSURL *imageURL = [url_ hasPrefix:@"file://"] ? [NSURL URLWithString:url] : [NSURL fileURLWithPath:url]; ++ // file:// and ph:// are already valid NSURLs; anything else is a raw path ++ NSURL *imageURL = ([url_ hasPrefix:@"file://"] || [url_ hasPrefix:@"ph://"]) ? [NSURL URLWithString:url] : [NSURL fileURLWithPath:url]; + CGFloat maxSize = MAX(maxWidth, maxHeight); + [self generateImageThumbnailAtURL:imageURL maxSize:maxSize completion:completionBlock failure:failBlock]; + } else if ([url_ hasPrefix:@"http://"] || [url_ hasPrefix:@"https://"]) { AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:vidURL options:@{@"AVURLAssetHTTPHeaderFieldsKey": headers}]; [self generateThumbImage:asset atTime:timeStamp maxWidth:maxWidth maxHeight:maxHeight timeToleranceMs:timeToleranceMs completion:completionBlock failure:failBlock]; ++ } else if ([url_ hasPrefix:@"ph://"]) { ++ // ph:// URIs are Photos Library asset identifiers. Neither AVAsset nor AVURLAsset can ++ // resolve them — PHImageManager is the only API with the right authorization. ++ // localIdentifier is everything after "ph://" (e.g. "UUID/L0/001"). ++ NSString *localIdentifier = [url substringFromIndex:5]; ++ PHFetchResult *fetchResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[localIdentifier] options:nil]; ++ PHAsset *phAsset = fetchResult.firstObject; ++ if (!phAsset) { ++ NSError *fetchError = [NSError errorWithDomain:@"CreateThumbnail" code:3 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"PHAsset not found for identifier: %@", localIdentifier]}]; ++ failBlock(fetchError); ++ return; ++ } ++ PHImageRequestOptions *requestOptions = [[PHImageRequestOptions alloc] init]; ++ requestOptions.synchronous = NO; ++ requestOptions.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; ++ requestOptions.networkAccessAllowed = NO; ++ CGSize targetSize = CGSizeMake(maxWidth, maxHeight); ++ [[PHImageManager defaultManager] requestImageForAsset:phAsset targetSize:targetSize contentMode:PHImageContentModeAspectFit options:requestOptions resultHandler:^(UIImage *thumbnail, NSDictionary *info) { ++ if (thumbnail) { ++ completionBlock(thumbnail); ++ } else { ++ NSError *imgError = [NSError errorWithDomain:@"CreateThumbnail" code:4 userInfo:@{NSLocalizedDescriptionKey: @"PHImageManager returned nil image"}]; ++ failBlock(imgError); ++ } ++ }]; } else { -@@ -120,6 +134,36 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi + AVAsset *asset = [AVAsset assetWithURL:vidURL]; + [self generateLocalMediaThumbImage:asset atTime:timeStamp completion:completionBlock failure:failBlock]; +@@ -120,6 +164,36 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi return; } - + +- (void) generateImageThumbnailAtURL:(NSURL *)imageURL maxSize:(CGFloat)maxSize completion:(void (^)(UIImage* thumbnail))completion failure:(void (^)(NSError* error))failure { + @autoreleasepool { + CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, NULL); @@ -77,7 +109,7 @@ index 4c87cfa..26da63e 100644 - (void) generateThumbImage:(AVURLAsset *)asset atTime:(int)timeStamp maxWidth:(CGFloat)maxWidth maxHeight:(CGFloat)maxHeight timeToleranceMs:(int)timeToleranceMs completion:(void (^)(UIImage* thumbnail))completion failure:(void (^)(NSError* error))failure { AVAssetImageGenerator *generator = [[AVAssetImageGenerator alloc] initWithAsset:asset]; generator.appliesPreferredTrackTransform = YES; -@@ -141,6 +185,7 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi +@@ -141,6 +215,7 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi - (void) generateLocalMediaThumbImage:(AVAsset *)asset atTime:(int)timeStamp completion:(void (^)(UIImage* thumbnail))completion failure:(void (^)(NSError* error))failure { AVAssetImageGenerator *imageGenerator = [[AVAssetImageGenerator alloc] initWithAsset:asset]; imageGenerator.appliesPreferredTrackTransform = YES; diff --git a/src/screens/PhotosScreen/components/PhotoItem.tsx b/src/screens/PhotosScreen/components/PhotoItem.tsx index d6bf66555..eb3cdda0e 100644 --- a/src/screens/PhotosScreen/components/PhotoItem.tsx +++ b/src/screens/PhotosScreen/components/PhotoItem.tsx @@ -1,5 +1,5 @@ import { LinearGradient } from 'expo-linear-gradient'; -import { ArrowUpIcon, CheckIcon, CloudIcon, CloudSlashIcon, ImageIcon } from 'phosphor-react-native'; +import { ArrowUpIcon, CheckIcon, CloudIcon, CloudSlashIcon, ImageIcon, PlayIcon } from 'phosphor-react-native'; import { memo, useCallback, useEffect, useRef } from 'react'; import { Animated, Easing, Image, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Circle } from 'react-native-progress'; @@ -72,6 +72,30 @@ const SelectOverlay = ({ ); }; +const VideoBadge = ({ duration }: { duration?: string }): JSX.Element => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + + return ( + + + {duration ? ( + + {duration} + + ) : ( + + )} + + ); +}; + const localPhotoCellAreEqual = (prev: CellProps & { item: PhotoItemType }, next: CellProps & { item: PhotoItemType }) => prev.item.id === next.item.id && prev.item.backupState === next.item.backupState && @@ -122,20 +146,7 @@ const LocalPhotoCell = memo( )} - {item.mediaType === 'video' && item.duration && ( - - - - {item.duration} - - - )} + {item.mediaType === 'video' && } @@ -176,6 +187,8 @@ const CloudPhotoCell = memo( + {item.mediaType === 'video' && } + ); diff --git a/src/services/common/uri/uriHelpers.ts b/src/services/common/uri/uriHelpers.ts index 66060fe3b..25b633527 100644 --- a/src/services/common/uri/uriHelpers.ts +++ b/src/services/common/uri/uriHelpers.ts @@ -1,7 +1,14 @@ export const FILE_URI_PREFIX = 'file://'; +/** + * Converts a raw filesystem path to a file:// URI. + * If the input already has any URI scheme (file://, ph://, content://, https://, …) + * it is returned unchanged — callers can pass any path or URI safely. + */ export const toFileUri = (path: string): string => { - if (path.startsWith(FILE_URI_PREFIX)) return path; + if (path.includes('://')) { + return path; + } const absolutePath = path.startsWith('/') ? path : `/${path}`; return `${FILE_URI_PREFIX}${encodeURI(decodeURIComponent(absolutePath))}`; }; diff --git a/src/services/photos/PhotoAssetFetchService.spec.ts b/src/services/photos/PhotoAssetFetchService.spec.ts index 529b99281..0280cc08c 100644 --- a/src/services/photos/PhotoAssetFetchService.spec.ts +++ b/src/services/photos/PhotoAssetFetchService.spec.ts @@ -132,21 +132,31 @@ describe('fetchUri — cloud item', () => { expect(result).toBeNull(); }); - test('when asset is not cached and has a fileId, then downloadFile is called and the cache path is returned', async () => { - mockGetCloudAssetById.mockResolvedValue({ fileId: 'file-id-123', fileSize: 500_000 }); + test('when asset is not cached and has a fileId, then downloadFile is called with the asset bucket and the cache path is returned', async () => { + mockGetCloudAssetById.mockResolvedValue({ fileId: 'file-id-123', fileSize: 500_000, bucket: 'photos-bucket' }); + mockGetUser.mockResolvedValue({ bucket: 'drive-root-bucket', mnemonic: 'mnemonic' }); (fileSystemService.pathToUri as jest.Mock).mockReturnValue('file:///cache/photo_preview/cloud-uuid-1.jpg'); const result = await PhotoAssetFetchService.fetchUri(makeCloudItem(), makeSignal()); expect(mockDownloadFile).toHaveBeenCalledWith( expect.anything(), - 'bucket-id', + 'photos-bucket', 'file-id-123', expect.objectContaining({ downloadPath: '/cache/photo_preview/cloud-uuid-1.jpg' }), 500_000, ); expect(result).toBe('file:///cache/photo_preview/cloud-uuid-1.jpg'); }); + + test('when asset is not cached but has no bucket, then null is returned without downloading', async () => { + mockGetCloudAssetById.mockResolvedValue({ fileId: 'file-id-123', fileSize: 500_000, bucket: null }); + + const result = await PhotoAssetFetchService.fetchUri(makeCloudItem(), makeSignal()); + + expect(mockDownloadFile).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); }); describe('resolveExportUri', () => { diff --git a/src/services/photos/PhotoAssetFetchService.ts b/src/services/photos/PhotoAssetFetchService.ts index b95d8e3ab..4021e2e5c 100644 --- a/src/services/photos/PhotoAssetFetchService.ts +++ b/src/services/photos/PhotoAssetFetchService.ts @@ -43,6 +43,10 @@ const downloadCloudAsset = async (item: CloudPhotoItem, signal: AbortSignal): Pr logger.warn(`[PhotoAssetFetchService] No fileId for cloud asset ${item.id}, skipping download`); return null; } + if (!asset.bucket) { + logger.warn(`[PhotoAssetFetchService] No bucket for cloud asset ${item.id}, skipping download`); + return null; + } const user = await asyncStorageService.getUser(); @@ -51,7 +55,7 @@ const downloadCloudAsset = async (item: CloudPhotoItem, signal: AbortSignal): Pr try { await driveFileService.downloadFile( user, - user.bucket, + asset.bucket, asset.fileId, { downloadPath: cachePath, diff --git a/src/services/photos/PhotoCloudBrowser.spec.ts b/src/services/photos/PhotoCloudBrowser.spec.ts index a4704a8e0..455294812 100644 --- a/src/services/photos/PhotoCloudBrowser.spec.ts +++ b/src/services/photos/PhotoCloudBrowser.spec.ts @@ -1,7 +1,7 @@ import { driveFolderService } from 'src/services/drive/folder/driveFolder.service'; import { photosLocalDB } from './database/photosLocalDB'; -import { photosDeviceService } from './photosDeviceService'; import { photoCloudBrowser } from './PhotoCloudBrowser'; +import { photosDeviceService } from './photosDeviceService'; jest.mock('src/services/drive/folder/driveFolder.service', () => ({ driveFolderService: { @@ -26,6 +26,8 @@ jest.mock('./database/photosLocalDB', () => ({ deleteCloudAsset: jest.fn(), getCloudAssetMonthsByDevice: jest.fn(), getSyncedMonths: jest.fn(), + getDistinctCloudAssetDeviceIds: jest.fn().mockResolvedValue([]), + deleteCloudAssetsByDevice: jest.fn(), }, })); @@ -78,10 +80,7 @@ describe('PhotoCloudBrowser.listDeviceFolders', () => { const result = await photoCloudBrowser.listDeviceFolders(); - expect(result).toEqual([ - { uuid: 'd1-uuid' }, - { uuid: 'd2-uuid' }, - ]); + expect(result).toEqual([{ uuid: 'd1-uuid' }, { uuid: 'd2-uuid' }]); }); test('when the device service returns a deleted device, then it is excluded from the list', async () => { @@ -564,4 +563,25 @@ describe('PhotoCloudBrowser.syncAllHistory', () => { expect(onMonthFetched).toHaveBeenCalledTimes(1); }); + + test('when local DB has a device ID not returned by the backend, then its cloud assets are deleted', async () => { + mockDeviceService.listDevices.mockResolvedValue([makeDevice('active-uuid', 'iPhone')]); + mockPhotosLocalDB.getDistinctCloudAssetDeviceIds.mockResolvedValue(['active-uuid', 'orphan-uuid']); + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(Infinity); + + await photoCloudBrowser.syncAllHistory({}); + + expect(mockPhotosLocalDB.deleteCloudAssetsByDevice).toHaveBeenCalledWith('orphan-uuid'); + expect(mockPhotosLocalDB.deleteCloudAssetsByDevice).not.toHaveBeenCalledWith('active-uuid'); + }); + + test('when all local device IDs match backend devices, then no cloud assets are deleted', async () => { + mockDeviceService.listDevices.mockResolvedValue([makeDevice('active-uuid', 'iPhone')]); + mockPhotosLocalDB.getDistinctCloudAssetDeviceIds.mockResolvedValue(['active-uuid']); + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(Infinity); + + await photoCloudBrowser.syncAllHistory({}); + + expect(mockPhotosLocalDB.deleteCloudAssetsByDevice).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/photos/PhotoCloudBrowser.ts b/src/services/photos/PhotoCloudBrowser.ts index 60d669708..f77ae2276 100644 --- a/src/services/photos/PhotoCloudBrowser.ts +++ b/src/services/photos/PhotoCloudBrowser.ts @@ -31,9 +31,7 @@ class PhotoCloudBrowserService { async listDeviceFolders(): Promise<{ uuid: string }[]> { const devices = await photosDeviceService.listDevices(); - return devices - .filter((device) => device.status === 'EXISTS') - .map((device) => ({ uuid: device.uuid })); + return devices.filter((device) => device.status === 'EXISTS').map((device) => ({ uuid: device.uuid })); } async fetchMonth(params: { @@ -70,11 +68,17 @@ class PhotoCloudBrowserService { currentDeviceId?: string; }): Promise { const { onMonthFetched, isCancelled, force, currentDeviceId } = options; + logger.info( + `[CloudBrowser] syncAllHistory — currentDeviceId=${currentDeviceId ?? 'none'}, force=${force ?? false}`, + ); const devices = await this.listDeviceFolders(); if (devices.length === 0) { logger.info('[CloudBrowser] No device folders found — skipping sync'); return; } + logger.info(`[CloudBrowser] Syncing ${devices.length} device(s): ${devices.map((d) => d.uuid).join(', ')}`); + + await this.purgeDeletedDevices(devices, onMonthFetched); const months = await this.discoverAvailableMonths(devices); @@ -189,7 +193,13 @@ class PhotoCloudBrowserService { currentDeviceId?: string; }): Promise { const { deviceId, year, month, foundIds, currentDeviceId } = params; + logger.info( + `[CloudBrowser] reconcileCloudDeletions — device=${deviceId} ${year}/${String(month).padStart(2, '0')}, foundIds=${[...foundIds].length}, currentDeviceId=${currentDeviceId ?? 'none'}`, + ); const knownFromCloud = await this.localDB.getCloudAssetRemoteIdsByDeviceAndMonth(deviceId, year, month); + logger.info( + `[CloudBrowser] reconcileCloudDeletions — knownFromCloud=${knownFromCloud.size} in local DB for device=${deviceId} ${year}/${String(month).padStart(2, '0')}`, + ); const knownIds = new Set(knownFromCloud); if (currentDeviceId && deviceId === currentDeviceId) { @@ -212,6 +222,24 @@ class PhotoCloudBrowserService { } } + private async purgeDeletedDevices(activeDevices: { uuid: string }[], onPurged?: () => void): Promise { + const activeDevicesIds = new Set(activeDevices.map((device) => device.uuid)); + const localIds = await this.localDB.getDistinctCloudAssetDeviceIds(); + const orphanedDeviceIds = localIds.filter((id) => !activeDevicesIds.has(id)); + if (orphanedDeviceIds.length === 0) { + return; + } + + logger.info( + `[CloudBrowser] Purging ${orphanedDeviceIds.length} deleted device(s) from local DB: ${orphanedDeviceIds.join(', ')}`, + ); + for (const deviceId of orphanedDeviceIds) { + await this.localDB.deleteCloudAssetsByDevice(deviceId); + logger.info(`[CloudBrowser] Purged all cloud_asset rows for deleted device=${deviceId}`); + } + onPurged?.(); + } + private async reconcileDeletedMonths(params: { devices: { uuid: string }[]; discoveredMonths: { deviceId: string; year: number; month: number; monthFolderUuid: string }[]; @@ -219,10 +247,16 @@ class PhotoCloudBrowserService { }): Promise { const { devices, discoveredMonths, currentDeviceId } = params; const discoveredSet = new Set(discoveredMonths.map((m) => `${m.deviceId}:${m.year}:${m.month}`)); + logger.info( + `[CloudBrowser] reconcileDeletedMonths — ${devices.length} device(s) to reconcile: ${devices.map((d) => d.uuid).join(', ')}`, + ); for (const device of devices) { const deviceId = device.uuid; const cloudMonths = await this.localDB.getCloudAssetMonthsByDevice(deviceId); + logger.info( + `[CloudBrowser] reconcileDeletedMonths — device=${deviceId}: ${cloudMonths.length} month(s) in local DB: ${JSON.stringify(cloudMonths)}`, + ); const monthSet = new Set(cloudMonths.map((m) => `${m.year}:${m.month}`)); if (currentDeviceId && deviceId === currentDeviceId) { diff --git a/src/services/photos/PhotoDeviceId.spec.ts b/src/services/photos/PhotoDeviceId.spec.ts index eff39305f..9b6d241e5 100644 --- a/src/services/photos/PhotoDeviceId.spec.ts +++ b/src/services/photos/PhotoDeviceId.spec.ts @@ -191,3 +191,28 @@ describe('PhotoDeviceManager.ensureDeviceFolder', () => { }); }); }); + +describe('PhotoDeviceManager concurrent calls', () => { + test('when ensureDeviceFolder is called twice before the first resolves, then the backend is only called once and both callers receive the same result', async () => { + mockGetItem.mockResolvedValue(null); + + let resolveCreate!: (device: ReturnType) => void; + mockCreateDevice.mockReturnValue( + new Promise((r) => { + resolveCreate = r; + }), + ); + + const [p1, p2] = [PhotoDeviceManager.ensureDeviceFolder(), PhotoDeviceManager.ensureDeviceFolder()]; + + // Flush microtasks so resolveDeviceFolder reaches the createDevice call + await new Promise((r) => setImmediate(r)); + resolveCreate(makeDevice('iPhone')); + + const [result1, result2] = await Promise.all([p1, p2]); + + expect(mockCreateDevice).toHaveBeenCalledTimes(1); + expect(result1.deviceId).toBe('folder-uuid-123'); + expect(result2.deviceId).toBe('folder-uuid-123'); + }); +}); diff --git a/src/services/photos/PhotoDeviceId.ts b/src/services/photos/PhotoDeviceId.ts index 7fab9a775..a6eb96a21 100644 --- a/src/services/photos/PhotoDeviceId.ts +++ b/src/services/photos/PhotoDeviceId.ts @@ -1,3 +1,4 @@ +import { PhotoDevice } from '@internxt/sdk/dist/drive/photos'; import * as Application from 'expo-application'; import * as Device from 'expo-device'; import { Platform } from 'react-native'; @@ -5,7 +6,6 @@ import uuid from 'react-native-uuid'; import { logger } from 'src/services/common'; import secureStorageService from 'src/services/SecureStorageService'; import { AsyncStorageKey } from 'src/types'; -import { PhotoDevice } from '@internxt/sdk/dist/drive/photos'; import { PhotoDeviceNameConflictError } from './errors'; import { photosDeviceService } from './photosDeviceService'; @@ -59,13 +59,25 @@ const parseDeviceInfo = (device: PhotoDevice): PhotoDeviceInfo => ({ * - Android: EncryptedSharedPreferences is wiped on uninstall, device is re-identified * by androidId (stable hardware key); on a 409 the existing folder is adopted by key. */ -export const PhotoDeviceManager = { - async ensureDeviceFolder(): Promise { +class PhotoDeviceManagerService { + private pendingDeviceFolder: Promise | null = null; + + ensureDeviceFolder(): Promise { + if (this.pendingDeviceFolder) { + return this.pendingDeviceFolder; + } + this.pendingDeviceFolder = this.resolveDeviceFolder().finally(() => { + this.pendingDeviceFolder = null; + }); + return this.pendingDeviceFolder; + } + + private async resolveDeviceFolder(): Promise { const storedUuid = await secureStorageService.getItem(AsyncStorageKey.PhotosDeviceId); if (storedUuid) { const existingDevice = await photosDeviceService.getDevice(storedUuid); - if (existingDevice && existingDevice.status === 'EXISTS') { + if (existingDevice?.status === 'EXISTS') { logger.info( TAG, `Reusing device folder uuid=${existingDevice.uuid} key="${existingDevice.plainName}" bucket=${existingDevice.bucket}`, @@ -97,5 +109,7 @@ export const PhotoDeviceManager = { } throw err; } - }, -}; + } +} + +export const PhotoDeviceManager = new PhotoDeviceManagerService(); diff --git a/src/services/photos/PhotoUploadService.spec.ts b/src/services/photos/PhotoUploadService.spec.ts index d5bc47c7d..f32567b0f 100644 --- a/src/services/photos/PhotoUploadService.spec.ts +++ b/src/services/photos/PhotoUploadService.spec.ts @@ -132,7 +132,7 @@ describe('PhotoUploadService.upload', () => { await PhotoUploadService.upload(asset, DEVICE_ID, PHOTOS_BUCKET); - expect(mockGenerateThumbnail).toHaveBeenCalledWith(LOCAL_PATH, 'jpg'); + expect(mockGenerateThumbnail).toHaveBeenCalledWith(LOCAL_URI, 'jpg'); expect(mockCreateThumbnailEntry).toHaveBeenCalledWith({ fileUuid: 'drive-file-uuid', type: 'JPEG', @@ -266,7 +266,7 @@ describe('PhotoUploadService.replace', () => { await PhotoUploadService.replace(asset, 'existing-remote-id', DEVICE_ID, PHOTOS_BUCKET); - expect(mockGenerateThumbnail).toHaveBeenCalledWith(LOCAL_PATH, 'jpg'); + expect(mockGenerateThumbnail).toHaveBeenCalledWith(LOCAL_URI, 'jpg'); expect(mockCreateThumbnailEntry).toHaveBeenCalledWith(expect.objectContaining({ fileUuid: 'existing-remote-id' })); }); diff --git a/src/services/photos/PhotoUploadService.ts b/src/services/photos/PhotoUploadService.ts index 5f43fa5f4..227f38b53 100644 --- a/src/services/photos/PhotoUploadService.ts +++ b/src/services/photos/PhotoUploadService.ts @@ -42,11 +42,14 @@ interface FileUploadResult { creationIso: string; folderUuid: string; localFilePath: string; + thumbnailSource: string; tempPath?: string; credentials: UploadCredentials; } -const resolveLocalPath = async (asset: MediaLibrary.Asset): Promise<{ localPath: string; tempPath?: string }> => { +const resolveLocalPath = async ( + asset: MediaLibrary.Asset, +): Promise<{ localPath: string; tempPath?: string; thumbnailUri?: string }> => { if (Platform.OS === 'ios') { const assetInfo = await photoMediaLibraryService.getAssetInfo(asset.id, { shouldDownloadFromNetwork: false }); const rawUri = assetInfo.localUri ?? asset.uri; @@ -54,7 +57,10 @@ const resolveLocalPath = async (asset: MediaLibrary.Asset): Promise<{ localPath: throw new Error(`Asset ${asset.id} has no local URI — may be stored in iCloud`); } - return { localPath: stripFileSchemeAndFragment(rawUri) }; + // Videos in the Photos Library are only accessible via PHAsset URI (ph://) for + // AVFoundation-based operations like thumbnail generation. Direct /var/mobile/Media/DCIM/ + // paths fail with NSCocoaErrorDomain 257 (no permission) when passed to AVAssetImageGenerator. + return { localPath: stripFileSchemeAndFragment(rawUri), thumbnailUri: asset.uri }; } const uri = asset.uri; @@ -74,7 +80,7 @@ const uploadAssetToBucket = async ( onProgress?: (ratio: number) => void, signal?: AbortSignal, ): Promise => { - const { localPath: localFilePath, tempPath } = await resolveLocalPath(asset); + const { localPath: localFilePath, tempPath, thumbnailUri } = await resolveLocalPath(asset); const createdDate = new Date(asset.creationTime); const creationIso = createdDate.toISOString(); @@ -125,6 +131,7 @@ const uploadAssetToBucket = async ( creationIso, folderUuid, localFilePath, + thumbnailSource: thumbnailUri ?? localFilePath, tempPath, credentials: { bucketId, encryptionKey, bridgeUser, bridgePass }, }; @@ -172,8 +179,8 @@ const uploadThumbnailForAsset = async ( bucketFile: thumbnailFileId, encryptVersion: EncryptionVersion.Aes03, }); - } catch { - logger.error(`Failed to upload thumbnail for file ${fileUuid}, with thumbnail path ${thumbnailPath}`); + } catch (err) { + logger.error(`Failed to upload thumbnail for file ${fileUuid} (ext=${fileExtension}, path=${localFilePath}):`, err); } finally { await cleanupTempFile(thumbnailPath); } @@ -236,6 +243,7 @@ export const PhotoUploadService = { creationIso, folderUuid, localFilePath, + thumbnailSource, tempPath, credentials, } = fileUploadResult; @@ -252,7 +260,7 @@ export const PhotoUploadService = { creationTime: creationIso, }); - await uploadThumbnailForAsset(localFilePath, fileExtension, fileUuid, credentials); + await uploadThumbnailForAsset(thumbnailSource, fileExtension, fileUuid, credentials); return fileUuid; } finally { @@ -272,6 +280,7 @@ export const PhotoUploadService = { fileId, fileSize, localFilePath, + thumbnailSource, fileExtension, tempPath, credentials, @@ -285,7 +294,7 @@ export const PhotoUploadService = { try { try { await uploadService.replaceFileEntry(existingRemoteFileId, { fileId, size: fileSize }); - await uploadThumbnailForAsset(localFilePath, fileExtension, existingRemoteFileId, credentials); + await uploadThumbnailForAsset(thumbnailSource, fileExtension, existingRemoteFileId, credentials); return existingRemoteFileId; } catch (replaceError) { if (!isDeletedOrTrashedError(replaceError)) { @@ -303,7 +312,7 @@ export const PhotoUploadService = { modificationTime: modificationIso, creationTime: creationIso, }); - await uploadThumbnailForAsset(localFilePath, fileExtension, driveFile.uuid, credentials); + await uploadThumbnailForAsset(thumbnailSource, fileExtension, driveFile.uuid, credentials); return driveFile.uuid; } } finally { diff --git a/src/services/photos/database/photosLocalDB.ts b/src/services/photos/database/photosLocalDB.ts index 970d7de69..360c6fbfe 100644 --- a/src/services/photos/database/photosLocalDB.ts +++ b/src/services/photos/database/photosLocalDB.ts @@ -272,6 +272,18 @@ class PhotosLocalDB { await sqliteService.executeSql(DB_NAME, assetSyncTable.statements.markCloudDeleted, [remoteFileId]); } + async getDistinctCloudAssetDeviceIds(): Promise { + const rows = await sqliteService.getAllAsync<{ device_id: string }>( + DB_NAME, + cloudAssetTable.statements.getDistinctDeviceIds, + ); + return rows.map((r) => r.device_id); + } + + async deleteCloudAssetsByDevice(deviceId: string): Promise { + await sqliteService.executeSql(DB_NAME, cloudAssetTable.statements.deleteByDevice, [deviceId]); + } + async getCloudAssetMonthsByDevice(deviceId: string): Promise<{ year: number; month: number }[]> { return sqliteService.getAllAsync<{ year: number; month: number }>( DB_NAME, diff --git a/src/services/photos/database/tables/asset_sync.ts b/src/services/photos/database/tables/asset_sync.ts index cfd269d6f..eb2980024 100644 --- a/src/services/photos/database/tables/asset_sync.ts +++ b/src/services/photos/database/tables/asset_sync.ts @@ -94,7 +94,7 @@ const statements = { AND creation_time >= ? AND creation_time < ?; `, - getPendingAssets: `SELECT asset_id, status, remote_file_id FROM ${TABLE_NAME} WHERE status NOT IN ('synced', 'deleted', 'cloud_deleted');`, + getPendingAssets: `SELECT asset_id, status, remote_file_id FROM ${TABLE_NAME} WHERE status NOT IN ('synced', 'deleted', 'cloud_deleted') ORDER BY creation_time DESC NULLS LAST;`, markDeleted: ` INSERT INTO ${TABLE_NAME} (asset_id, status, deleted_at) VALUES (?, 'deleted', (unixepoch() * 1000)) diff --git a/src/services/photos/database/tables/cloud_asset.ts b/src/services/photos/database/tables/cloud_asset.ts index 63b69e98d..7c1b90992 100644 --- a/src/services/photos/database/tables/cloud_asset.ts +++ b/src/services/photos/database/tables/cloud_asset.ts @@ -85,6 +85,8 @@ const statements = { `, delete: `DELETE FROM ${TABLE_NAME} WHERE remote_file_id = ?;`, + deleteByDevice: `DELETE FROM ${TABLE_NAME} WHERE device_id = ?;`, + getDistinctDeviceIds: `SELECT DISTINCT device_id FROM ${TABLE_NAME};`, reset: `DELETE FROM ${TABLE_NAME};`, getRemoteIdsByDeviceAndMonth: `