diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 1b1671dac8..181b343c6e 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -144,7 +144,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { private func getMediaChanges() throws -> SyncDelta { guard #available(iOS 16, *) else { - throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) + throw PigeonError(code: kUnSupportedOSError, message: "This feature requires iOS 16 or later.", details: nil) } guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { @@ -437,7 +437,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } func getTrashedAssets() throws -> [String: [PlatformAsset]] { - throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) + throw PigeonError(code: kUnSupportedOSError, message: "This feature not supported on iOS.", details: nil) } func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result) -> Void) { @@ -477,9 +477,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] { guard #available(iOS 16, *) else { - return assetIds.map { - CloudIdResult(assetId: $0, error: "Cloud identifiers require iOS 16", errorKind: .unsupported) - } + throw PigeonError(code: kUnSupportedOSError, message: "This feature requires iOS 16 or later.", details: nil) } var mappings: [CloudIdResult] = [] diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 3abae181fe..250015acda 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -108,7 +108,7 @@ class LocalSyncService { await updateAlbum(dbAlbum, album); } - await _mapIosCloudIds(newAssets); + await _mapCloudIds(newAssets); } await _nativeSyncApi.checkpointSync(); } on PlatformException catch (e, s) { @@ -167,7 +167,7 @@ class LocalSyncService { : []; await _localAlbumRepository.upsert(album, toUpsert: assets); - await _mapIosCloudIds(assets); + await _mapCloudIds(assets); _log.fine("Successfully added device album ${album.name}"); } catch (e, s) { _log.warning("Error while adding device album", e, s); @@ -249,7 +249,7 @@ class LocalSyncService { toUpsert: newAssets, ); - await _mapIosCloudIds(newAssets); + await _mapCloudIds(newAssets); return true; } catch (e, s) { _log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s); @@ -281,7 +281,7 @@ class LocalSyncService { if (dbAlbum.assetCount == 0) { _log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB."); await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice); - await _mapIosCloudIds(assetsInDevice); + await _mapCloudIds(assetsInDevice); return true; } @@ -319,7 +319,7 @@ class LocalSyncService { } await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete); - await _mapIosCloudIds(assetsToUpsert); + await _mapCloudIds(assetsToUpsert); return true; } catch (e, s) { @@ -328,12 +328,17 @@ class LocalSyncService { return true; } - Future _mapIosCloudIds(List assets) async { + Future _mapCloudIds(List assets) async { if (!CurrentPlatform.isIOS || assets.isEmpty) { return; } - await resolveCloudIds(_nativeSyncApi, _localAlbumRepository, assets.map((a) => a.id).toList()); + await resolveCloudIds( + _nativeSyncApi, + _localAlbumRepository, + assets.map((a) => a.id).toList(), + cancellation: _cancellation, + ); } bool _assetsEqual(LocalAsset a, LocalAsset b) { diff --git a/mobile/lib/domain/utils/cloud_id_resolver.dart b/mobile/lib/domain/utils/cloud_id_resolver.dart index 3f3ef1aeae..21e50e8da4 100644 --- a/mobile/lib/domain/utils/cloud_id_resolver.dart +++ b/mobile/lib/domain/utils/cloud_id_resolver.dart @@ -1,9 +1,13 @@ import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:logging/logging.dart'; +@visibleForTesting const kCloudIdChunkSize = 5000; Future resolveCloudIds( @@ -14,31 +18,36 @@ Future resolveCloudIds( }) async { final logger = Logger('resolveCloudIds'); - for (int offset = 0; offset < assetIds.length; offset += kCloudIdChunkSize) { + for (final batch in assetIds.slices(kCloudIdChunkSize)) { if (cancellation?.isCompleted ?? false) { - logger.warning('Cloud ID resolution cancelled after $offset of ${assetIds.length} assets'); + logger.warning('Cloud ID resolution cancelled'); return; } - final end = offset + kCloudIdChunkSize; - final chunk = assetIds.sublist(offset, end > assetIds.length ? assetIds.length : end); - - final cloudMapping = {}; - for (final result in await nativeSyncApi.getCloudIdForAssetIds(chunk)) { - if (result.cloudId != null) { - cloudMapping[result.assetId] = result.cloudId!; - continue; - } - - if (result.errorKind == CloudIdErrorKind.unsupported) { - logger.warning('Cloud IDs unavailable: ${result.error ?? "unsupported"}'); + final List results; + try { + results = await nativeSyncApi.getCloudIdForAssetIds(batch); + } on PlatformException catch (error, stack) { + if (error.code == kUnSupportedOSError) { + logger.warning('Cloud IDs are unavailable on this device. Skipping resolution.', error, stack); return; } - logger.fine( - 'Cannot fetch cloudId for asset with id: ${result.assetId}. ' - 'Reason: ${result.errorKind?.name ?? "unknown"}. Error: ${result.error ?? "unknown"}', - ); + logger.warning('Cannot fetch cloudIds for ${batch.length} assets. Skipping batch.', error, stack); + continue; + } + + final cloudMapping = {}; + for (final CloudIdResult(:assetId, :cloudId, :error, :errorKind) in results) { + if (cloudId == null) { + logger.fine( + 'Cannot fetch cloudId for asset with id: $assetId. ' + 'Reason: ${errorKind?.name ?? "unknown"}. Error: ${error ?? "unknown"}', + ); + continue; + } + + cloudMapping[assetId] = cloudId; } await albumRepository.updateCloudMapping(cloudMapping); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 8ff7071c95..de0daf8aa6 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -135,7 +135,7 @@ class AppLifeCycleNotifier extends StateNotifier { unawaited(_resumeBackup()); }), _resumeBackup(), - _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"), + _safeRun(backgroundManager.syncCloudIds, "syncCloudIds"), ]); } else { await _safeRun(backgroundManager.hashAssets, "hashAssets"); diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index 1a16f79445..f5a6d55e02 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -11,6 +11,8 @@ import 'package:pigeon/pigeon.dart'; dartPackageName: 'immich_mobile', ), ) +const String kUnSupportedOSError = 'UNSUPPORTED_OS'; + enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } class PlatformAsset { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index ac19481079..d7d03c1177 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1362,10 +1362,10 @@ packages: dependency: "direct dev" description: name: pigeon - sha256: "04cfefc8add8b47ddf9ccac8b92bb4edeb67c87f185c623ba0db118ac99334ad" + sha256: f90254ef7b22db026fd8d5fd44718fd60697d50968a144a8bc251e5e95d79e10 url: "https://pub.dev" source: hosted - version: "26.3.4" + version: "27.3.0" pinput: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 32e67de4f6..10d8e9392a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -110,7 +110,7 @@ dev_dependencies: sdk: flutter mocktail: ^1.0.5 # Type safe platform code - pigeon: ^26.3.4 + pigeon: ^27.3.0 # cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API. # Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x. diff --git a/mobile/test/medium/utils/migrate_cloud_ids_test.dart b/mobile/test/medium/utils/migrate_cloud_ids_test.dart index c45d3b34e6..38585dd755 100644 --- a/mobile/test/medium/utils/migrate_cloud_ids_test.dart +++ b/mobile/test/medium/utils/migrate_cloud_ids_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:drift/drift.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/utils/cloud_id_resolver.dart'; import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart'; @@ -84,11 +85,9 @@ void main() { test('stops after the first chunk when cloud IDs are unsupported', () async { final ids = List.generate(kCloudIdChunkSize + 1, (i) => 'asset-$i'); - when(() => mockNativeSyncApi.getCloudIdForAssetIds(any())).thenAnswer( - (invocation) async => (invocation.positionalArguments.first as List) - .map((id) => CloudIdResult(assetId: id, error: 'needs iOS 16', errorKind: .unsupported)) - .toList(), - ); + when( + () => mockNativeSyncApi.getCloudIdForAssetIds(any()), + ).thenThrow(PlatformException(code: kUnSupportedOSError)); await resolveCloudIds(mockNativeSyncApi, albumRepository, ids);