diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index e55ae54256..bda1276f56 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -401,8 +401,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { var mappings: [String: String?] = [:] let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds) for (key, value) in result { - let id = try? value.get().stringValue - mappings[key] = id + // Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH" + if let cloudId = try? value.get().stringValue, !cloudId.hasSuffix(":") { + mappings[key] = cloudId + } } return mappings; } diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 5774a13c90..3d62e85c5b 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/constants/constants.dart'; + part 'local_asset.model.dart'; part 'remote_asset.model.dart'; diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index e5c65bcf49..a8106a8c9c 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; @@ -18,6 +19,7 @@ import 'package:logging/logging.dart'; class LocalSyncService { final DriftLocalAlbumRepository _localAlbumRepository; + final DriftLocalAssetRepository _localAssetRepository; final NativeSyncApi _nativeSyncApi; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final LocalFilesManagerRepository _localFilesManager; @@ -26,11 +28,13 @@ class LocalSyncService { LocalSyncService({ required DriftLocalAlbumRepository localAlbumRepository, + required DriftLocalAssetRepository localAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required LocalFilesManagerRepository localFilesManager, required StorageRepository storageRepository, required NativeSyncApi nativeSyncApi, }) : _localAlbumRepository = localAlbumRepository, + _localAssetRepository = localAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository, _localFilesManager = localFilesManager, _storageRepository = storageRepository, @@ -47,6 +51,12 @@ class LocalSyncService { _log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing"); } } + + if (CurrentPlatform.isIOS) { + final assets = await _localAssetRepository.getEmptyCloudIdAssets(); + await _mapIosCloudIds(assets); + } + if (full || await _nativeSyncApi.shouldFullSync()) { _log.fine("Full sync request from ${full ? "user" : "native"}"); return await fullSync(); diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart index 71c6ffd4c0..0419b7a3a5 100644 --- a/mobile/lib/domain/utils/migrate_cloud_ids.dart +++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; @@ -32,7 +33,7 @@ Future syncCloudIds(ProviderContainer ref) async { for (final mapping in mappingsToUpdate) { final mobileMeta = AssetMetadataUpsertItemDto( key: AssetMetadataKey.mobileApp, - value: RemoteAssetMobileAppMetadata(cloudId: mapping.cloudId), + value: RemoteAssetMobileAppMetadata(cloudId: mapping.cloudId, eTag: mapping.eTag), ); try { await assetApi.updateAssetMetadata(mapping.assetId, AssetMetadataUpsertDto(items: [mobileMeta])); @@ -51,7 +52,7 @@ Future _populateCloudIds(Drift drift) async { await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping); } -typedef _CloudIdMapping = ({String assetId, String cloudId}); +typedef _CloudIdMapping = ({String assetId, String cloudId, String eTag}); Future> _fetchCloudIdMappings(Drift drift, String userId) async { final query = @@ -67,16 +68,31 @@ Future> _fetchCloudIdMappings(Drift drift, String userId) useColumns: false, ), ]) - ..addColumns([drift.remoteAssetEntity.id, drift.localAssetEntity.iCloudId]) + ..addColumns([ + drift.remoteAssetEntity.id, + drift.localAssetEntity.iCloudId, + drift.localAssetEntity.createdAt, + drift.localAssetEntity.adjustmentTime, + drift.localAssetEntity.latitude, + drift.localAssetEntity.longitude, + ]) ..where( drift.localAssetEntity.id.isNotNull() & drift.localAssetEntity.iCloudId.isNotNull() & drift.remoteAssetEntity.ownerId.equals(userId) & drift.remoteAssetCloudIdEntity.cloudId.isNull(), ); - return query - .map( - (row) => (assetId: row.read(drift.remoteAssetEntity.id)!, cloudId: row.read(drift.localAssetEntity.iCloudId)!), - ) - .get(); + return query.map((row) { + final createdAt = row.read(drift.localAssetEntity.createdAt)!; + final adjustmentTime = row.read(drift.localAssetEntity.adjustmentTime); + final latitude = row.read(drift.localAssetEntity.latitude); + final longitude = row.read(drift.localAssetEntity.longitude); + final eTag = + "${createdAt.millisecondsSinceEpoch ~/ 1000}$kUploadETagDelimiter${(adjustmentTime?.millisecondsSinceEpoch ?? 0) ~/ 1000}$kUploadETagDelimiter${latitude ?? 0}$kUploadETagDelimiter${longitude ?? 0}"; + return ( + assetId: row.read(drift.remoteAssetEntity.id)!, + cloudId: row.read(drift.localAssetEntity.iCloudId)!, + eTag: eTag, + ); + }).get(); } diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index e2ad444ef6..7e21f3ba39 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -127,6 +127,11 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return result; } + Future> getEmptyCloudIdAssets() { + final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull()); + return query.map((row) => row.toDto()).get(); + } + Future> getHashMappingFromCloudId() async { final createdAt = coalesce([_db.localAssetEntity.createdAt.strftime('%s'), const Constant('0')]); final adjustmentTime = coalesce([_db.localAssetEntity.adjustmentTime.strftime('%s'), const Constant('0')]); diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart index 2b7034770b..579b4c1d58 100644 --- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -131,6 +131,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id); properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', '))); if (CurrentPlatform.isIOS) { + properties.add(_PropertyItem(label: 'Cloud ID', value: asset.cloudId)); properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString())); } properties.add( diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 6ba9c4bb78..29dee6f726 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -32,6 +32,7 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref. final localSyncServiceProvider = Provider( (ref) => LocalSyncService( localAlbumRepository: ref.watch(localAlbumRepository), + localAssetRepository: ref.watch(localAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), localFilesManager: ref.watch(localFilesManagerRepositoryProvider), storageRepository: ref.watch(storageRepositoryProvider), diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index 92ab01c7e0..66434a3068 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; @@ -25,6 +26,7 @@ import '../../repository.mocks.dart'; void main() { late LocalSyncService sut; late DriftLocalAlbumRepository mockLocalAlbumRepository; + late DriftLocalAssetRepository mockLocalAssetRepository; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; late LocalFilesManagerRepository mockLocalFilesManager; late StorageRepository mockStorageRepository; @@ -47,6 +49,7 @@ void main() { setUp(() async { mockLocalAlbumRepository = MockLocalAlbumRepository(); + mockLocalAssetRepository = MockLocalAssetRepository(); mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); mockLocalFilesManager = MockLocalFilesManagerRepository(); mockStorageRepository = MockStorageRepository(); @@ -66,6 +69,7 @@ void main() { sut = LocalSyncService( localAlbumRepository: mockLocalAlbumRepository, + localAssetRepository: mockLocalAssetRepository, trashedLocalAssetRepository: mockTrashedLocalAssetRepository, localFilesManager: mockLocalFilesManager, storageRepository: mockStorageRepository,