diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index a197922f1a..5862157698 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -2,12 +2,12 @@ package app.alextran.immich import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine -import app.alextran.immich.platform.MessagesImpl +import app.alextran.immich.platform.ImHostApiImpl class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) flutterEngine.plugins.add(BackgroundServicePlugin()) - ImHostService.setUp(flutterEngine.dartExecutor.binaryMessenger, MessagesImpl(this)) + ImHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ImHostApiImpl(this)) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt index 9f9cad7dec..ef91c283c0 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MediaManager.kt @@ -80,6 +80,7 @@ class MediaManager(context: Context) { val currentVolumes = MediaStore.getExternalVolumeNames(ctx) val changed = mutableListOf() val deleted = mutableListOf() + val albumAssets = mutableMapOf>() var hasChanges = genMap.keys != currentVolumes for (volume in currentVolumes) { @@ -160,15 +161,15 @@ class MediaManager(context: Context) { mediaType.toLong(), createdAt, modifiedAt, - duration, - listOf(bucketId) + duration ) ) + albumAssets.put(id, listOf(bucketId)) } } } // Unmounted volumes are handled in dart when the album is removed - return SyncDelta(hasChanges, changed, deleted) + return SyncDelta(hasChanges, changed, deleted, albumAssets) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt index 7ceef58786..fb6b92f3ce 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/Messages.g.kt @@ -84,8 +84,7 @@ data class PlatformAsset ( val type: Long, val createdAt: Long? = null, val updatedAt: Long? = null, - val durationInSeconds: Long, - val albumIds: List + val durationInSeconds: Long ) { companion object { @@ -96,8 +95,7 @@ data class PlatformAsset ( val createdAt = pigeonVar_list[3] as Long? val updatedAt = pigeonVar_list[4] as Long? val durationInSeconds = pigeonVar_list[5] as Long - val albumIds = pigeonVar_list[6] as List - return PlatformAsset(id, name, type, createdAt, updatedAt, durationInSeconds, albumIds) + return PlatformAsset(id, name, type, createdAt, updatedAt, durationInSeconds) } } fun toList(): List { @@ -108,7 +106,6 @@ data class PlatformAsset ( createdAt, updatedAt, durationInSeconds, - albumIds, ) } override fun equals(other: Any?): Boolean { @@ -127,7 +124,8 @@ data class PlatformAsset ( data class SyncDelta ( val hasChanges: Boolean, val updates: List, - val deletes: List + val deletes: List, + val albumAssets: Map> ) { companion object { @@ -135,7 +133,8 @@ data class SyncDelta ( val hasChanges = pigeonVar_list[0] as Boolean val updates = pigeonVar_list[1] as List val deletes = pigeonVar_list[2] as List - return SyncDelta(hasChanges, updates, deletes) + val albumAssets = pigeonVar_list[3] as Map> + return SyncDelta(hasChanges, updates, deletes, albumAssets) } } fun toList(): List { @@ -143,6 +142,7 @@ data class SyncDelta ( hasChanges, updates, deletes, + albumAssets, ) } override fun equals(other: Any?): Boolean { @@ -188,7 +188,7 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface ImHostService { +interface ImHostApi { fun shouldFullSync(): Boolean fun getMediaChanges(): SyncDelta fun checkpointSync() @@ -196,17 +196,17 @@ interface ImHostService { fun getAssetIdsForAlbum(albumId: String): List companion object { - /** The codec used by ImHostService. */ + /** The codec used by ImHostApi. */ val codec: MessageCodec by lazy { MessagesPigeonCodec() } - /** Sets up an instance of `ImHostService` to handle messages through the `binaryMessenger`. */ + /** Sets up an instance of `ImHostApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads - fun setUp(binaryMessenger: BinaryMessenger, api: ImHostService?, messageChannelSuffix: String = "") { + fun setUp(binaryMessenger: BinaryMessenger, api: ImHostApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val taskQueue = binaryMessenger.makeBackgroundTaskQueue() run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.shouldFullSync$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { _, reply -> val wrapped: List = try { @@ -221,7 +221,7 @@ interface ImHostService { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue) if (api != null) { channel.setMessageHandler { _, reply -> val wrapped: List = try { @@ -236,7 +236,7 @@ interface ImHostService { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.checkpointSync$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { _, reply -> val wrapped: List = try { @@ -252,7 +252,7 @@ interface ImHostService { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.clearSyncCheckpoint$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.clearSyncCheckpoint$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { _, reply -> val wrapped: List = try { @@ -268,7 +268,7 @@ interface ImHostService { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt index c61c688273..59178ccb5e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/platform/MessagesImpl.kt @@ -1,12 +1,12 @@ package app.alextran.immich.platform -import ImHostService +import ImHostApi import SyncDelta import android.content.Context import android.os.Build import android.os.ext.SdkExtensions -class MessagesImpl(context: Context) : ImHostService { +class ImHostApiImpl(context: Context) : ImHostApi { private val ctx: Context = context.applicationContext private val mediaManager: MediaManager = MediaManager(ctx) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index e13e7b59c9..57132fbc42 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -25,7 +25,7 @@ import UIKit // Register pigeon handler let controller: FlutterViewController = window?.rootViewController as! FlutterViewController - ImHostServiceSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ImHostServiceImpl()) + ImHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ImHostApiImpl()) BackgroundServicePlugin.setPluginRegistrantCallback { registry in if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { diff --git a/mobile/ios/Runner/Platform/MediaManager.swift b/mobile/ios/Runner/Platform/MediaManager.swift index 6badc0e2e5..771f5866dc 100644 --- a/mobile/ios/Runner/Platform/MediaManager.swift +++ b/mobile/ios/Runner/Platform/MediaManager.swift @@ -97,7 +97,7 @@ class MediaManager { let currentToken = PHPhotoLibrary.shared().currentChangeToken if storedToken == currentToken { - return SyncDelta(hasChanges: false, updates: [], deletes: []) + return SyncDelta(hasChanges: false, updates: [], deletes: [], albumAssets: [:]) } do { @@ -110,6 +110,7 @@ class MediaManager { guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) + deletedAssets.formUnion(details.deletedLocalIdentifiers) if (updated.isEmpty) { continue } let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil) @@ -117,7 +118,7 @@ class MediaManager { let asset = result.object(at: i) // Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes - let predicate = PlatformAsset(id: asset.localIdentifier, name: "", type: 0, createdAt: nil, updatedAt: nil, durationInSeconds: 0, albumIds: []) + let predicate = PlatformAsset(id: asset.localIdentifier, name: "", type: 0, createdAt: nil, updatedAt: nil, durationInSeconds: 0) if (updatedAssets.contains(AssetWrapper(with: predicate))) { continue } @@ -136,37 +137,33 @@ class MediaManager { createdAt: createdAt.map { Int64($0) }, updatedAt: updatedAt.map { Int64($0) }, durationInSeconds: durationInSeconds, - albumIds: self._getAlbumIds(forAsset: asset) )) updatedAssets.insert(domainAsset) } - - deletedAssets.formUnion(details.deletedLocalIdentifiers) } - return SyncDelta(hasChanges: true, updates: Array(updatedAssets.map { $0.asset }), deletes: Array(deletedAssets)) + let updates = Array(updatedAssets.map { $0.asset }) + return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), albumAssets: buildAlbumAssetsMap(assets: updates)) } } - @available(iOS 16, *) - func _getAlbumIds(forAsset: PHAsset) -> [String] { - var albumIds: [String] = [] + private func buildAlbumAssetsMap(assets: Array) -> [String: [String]] { + var albumAssets: [String: [String]] = [:] let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] - + albumTypes.forEach { type in let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) collections.enumerateObjects { (album, _, _) in - var options = PHFetchOptions() - options.fetchLimit = 1 - options.predicate = NSPredicate(format: "localIdentifier == %@", forAsset.localIdentifier) + let options = PHFetchOptions() + options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id)) let result = PHAsset.fetchAssets(in: album, options: options) - if(result.count == 1) { - albumIds.append(album.localIdentifier) + for i in 0.. [Any?] { @@ -167,7 +164,6 @@ struct PlatformAsset: Hashable { createdAt, updatedAt, durationInSeconds, - albumIds, ] } static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { @@ -182,6 +178,7 @@ struct SyncDelta: Hashable { var hasChanges: Bool var updates: [PlatformAsset] var deletes: [String] + var albumAssets: [String: [String]] // swift-format-ignore: AlwaysUseLowerCamelCase @@ -189,11 +186,13 @@ struct SyncDelta: Hashable { let hasChanges = pigeonVar_list[0] as! Bool let updates = pigeonVar_list[1] as! [PlatformAsset] let deletes = pigeonVar_list[2] as! [String] + let albumAssets = pigeonVar_list[3] as! [String: [String]] return SyncDelta( hasChanges: hasChanges, updates: updates, - deletes: deletes + deletes: deletes, + albumAssets: albumAssets ) } func toList() -> [Any?] { @@ -201,6 +200,7 @@ struct SyncDelta: Hashable { hasChanges, updates, deletes, + albumAssets, ] } static func == (lhs: SyncDelta, rhs: SyncDelta) -> Bool { @@ -252,7 +252,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol ImHostService { +protocol ImHostApi { func shouldFullSync() throws -> Bool func getMediaChanges() throws -> SyncDelta func checkpointSync() throws @@ -261,17 +261,17 @@ protocol ImHostService { } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class ImHostServiceSetup { +class ImHostApiSetup { static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } - /// Sets up an instance of `ImHostService` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImHostService?, messageChannelSuffix: String = "") { + /// Sets up an instance of `ImHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImHostApi?, messageChannelSuffix: String = "") { let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" #if os(iOS) let taskQueue = binaryMessenger.makeBackgroundTaskQueue?() #else let taskQueue: FlutterTaskQueue? = nil #endif - let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { shouldFullSyncChannel.setMessageHandler { _, reply in do { @@ -285,8 +285,8 @@ class ImHostServiceSetup { shouldFullSyncChannel.setMessageHandler(nil) } let getMediaChangesChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) if let api = api { getMediaChangesChannel.setMessageHandler { _, reply in do { @@ -299,7 +299,7 @@ class ImHostServiceSetup { } else { getMediaChangesChannel.setMessageHandler(nil) } - let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { checkpointSyncChannel.setMessageHandler { _, reply in do { @@ -312,7 +312,7 @@ class ImHostServiceSetup { } else { checkpointSyncChannel.setMessageHandler(nil) } - let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { clearSyncCheckpointChannel.setMessageHandler { _, reply in do { @@ -326,8 +326,8 @@ class ImHostServiceSetup { clearSyncCheckpointChannel.setMessageHandler(nil) } let getAssetIdsForAlbumChannel = taskQueue == nil - ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) if let api = api { getAssetIdsForAlbumChannel.setMessageHandler { message, reply in let args = message as! [Any?] diff --git a/mobile/ios/Runner/Platform/MessagesImpl.swift b/mobile/ios/Runner/Platform/MessagesImpl.swift index c0bf245998..a58fee51c6 100644 --- a/mobile/ios/Runner/Platform/MessagesImpl.swift +++ b/mobile/ios/Runner/Platform/MessagesImpl.swift @@ -1,6 +1,6 @@ import Photos -class ImHostServiceImpl: ImHostService { +class ImHostApiImpl: ImHostApi { private let mediaManager: MediaManager diff --git a/mobile/lib/domain/interfaces/album_media.interface.dart b/mobile/lib/domain/interfaces/album_media.interface.dart index feb3adeb25..965db9ce89 100644 --- a/mobile/lib/domain/interfaces/album_media.interface.dart +++ b/mobile/lib/domain/interfaces/album_media.interface.dart @@ -2,18 +2,12 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/local_album.model.dart'; abstract interface class IAlbumMediaRepository { - Future> getAll(); + Future> getAll({bool withModifiedTime = false}); Future> getAssetsForAlbum( String albumId, { DateTimeFilter? updateTimeCond, }); - - Future refresh( - String albumId, { - bool withModifiedTime = true, - bool withAssetCount = true, - }); } class DateTimeFilter { diff --git a/mobile/lib/domain/services/device_sync.service.dart b/mobile/lib/domain/services/device_sync.service.dart index f6049dce34..9ee3340039 100644 --- a/mobile/lib/domain/services/device_sync.service.dart +++ b/mobile/lib/domain/services/device_sync.service.dart @@ -15,60 +15,59 @@ class DeviceSyncService { final IAlbumMediaRepository _albumMediaRepository; final ILocalAlbumRepository _localAlbumRepository; final Platform _platform; - final platform.ImHostService _hostService; + final platform.ImHostApi _hostApi; final Logger _log = Logger("DeviceSyncService"); DeviceSyncService({ required IAlbumMediaRepository albumMediaRepository, required ILocalAlbumRepository localAlbumRepository, - required platform.ImHostService hostService, + required platform.ImHostApi hostApi, Platform? platform, }) : _albumMediaRepository = albumMediaRepository, _localAlbumRepository = localAlbumRepository, _platform = platform ?? const LocalPlatform(), - _hostService = hostService; + _hostApi = hostApi; Future sync() async { final Stopwatch stopwatch = Stopwatch()..start(); try { - if (await _hostService.shouldFullSync()) { + if (await _hostApi.shouldFullSync()) { _log.fine("Cannot use partial sync. Performing full sync"); return await fullSync(); } - final delta = await _hostService.getMediaChanges(); + final delta = await _hostApi.getMediaChanges(); if (!delta.hasChanges) { _log.fine("No media changes detected. Skipping sync"); return; } - final deviceAlbums = await _albumMediaRepository.getAll(); + final deviceAlbums = + await _albumMediaRepository.getAll(withModifiedTime: true); await _localAlbumRepository.updateAll(deviceAlbums); await _localAlbumRepository.processDelta(delta); if (_platform.isAndroid) { final dbAlbums = await _localAlbumRepository.getAll(); for (final album in dbAlbums) { - final deviceIds = await _hostService.getAssetIdsForAlbum(album.id); + final deviceIds = await _hostApi.getAssetIdsForAlbum(album.id); await _localAlbumRepository.syncAlbumDeletes(album.id, deviceIds); } } - await _hostService.checkpointSync(); + await _hostApi.checkpointSync(); } catch (e, s) { _log.severe("Error performing device sync", e, s); + } finally { + stopwatch.stop(); + _log.info("Device sync took - ${stopwatch.elapsedMilliseconds}ms"); } - stopwatch.stop(); - _log.info("Device sync took - ${stopwatch.elapsedMilliseconds}ms"); } Future fullSync() async { try { final Stopwatch stopwatch = Stopwatch()..start(); - // The deviceAlbums will not have the updatedAt field - // and the assetCount will be 0. They are refreshed later - // after the comparison. The orderby in the filter sorts the assets - // and not the albums. + final deviceAlbums = (await _albumMediaRepository.getAll()).sortedBy((a) => a.id); @@ -84,7 +83,7 @@ class DeviceSyncService { onlySecond: addAlbum, ); - await _hostService.checkpointSync(); + await _hostApi.checkpointSync(); stopwatch.stop(); _log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms"); } catch (e, s) { @@ -92,10 +91,9 @@ class DeviceSyncService { } } - Future addAlbum(LocalAlbum newAlbum) async { + Future addAlbum(LocalAlbum album) async { try { - _log.fine("Adding device album ${newAlbum.name}"); - final album = await _albumMediaRepository.refresh(newAlbum.id); + _log.fine("Adding device album ${album.name}"); final assets = album.assetCount > 0 ? await _albumMediaRepository.getAssetsForAlbum(album.id) @@ -119,15 +117,11 @@ class DeviceSyncService { } // The deviceAlbum is ignored since we are going to refresh it anyways - FutureOr updateAlbum(LocalAlbum dbAlbum, LocalAlbum _) async { + FutureOr updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async { try { _log.fine("Syncing device album ${dbAlbum.name}"); - final deviceAlbum = await _albumMediaRepository.refresh(dbAlbum.id); - - // Early return if album hasn't changed - if (deviceAlbum.updatedAt.isAtSameMomentAs(dbAlbum.updatedAt) && - deviceAlbum.assetCount == dbAlbum.assetCount) { + if (_albumsEqual(deviceAlbum, dbAlbum)) { _log.fine( "Device album ${dbAlbum.name} has not changed. Skipping sync.", ); @@ -293,4 +287,10 @@ class DeviceSyncService { a.height == b.height && a.durationInSeconds == b.durationInSeconds; } + + bool _albumsEqual(LocalAlbum a, LocalAlbum b) { + return a.name == b.name && + a.assetCount == b.assetCount && + a.updatedAt.isAtSameMomentAs(b.updatedAt); + } } diff --git a/mobile/lib/infrastructure/repositories/album_media.repository.dart b/mobile/lib/infrastructure/repositories/album_media.repository.dart index 2435c4ac7a..4e50eeee32 100644 --- a/mobile/lib/infrastructure/repositories/album_media.repository.dart +++ b/mobile/lib/infrastructure/repositories/album_media.repository.dart @@ -11,24 +11,20 @@ class AlbumMediaRepository implements IAlbumMediaRepository { const AlbumMediaRepository({Platform platform = const LocalPlatform()}) : _platform = platform; - PMFilter _getAlbumFilter({ - withAssetTitle = false, - withModifiedTime = false, - DateTimeFilter? updateTimeCond, - }) => + PMFilter _getAlbumFilter({DateTimeFilter? updateTimeCond}) => FilterOptionGroup( - imageOption: FilterOption( + imageOption: const FilterOption( // needTitle is expected to be slow on iOS but is required to fetch the asset title - needTitle: withAssetTitle, - sizeConstraint: const SizeConstraint(ignoreSize: true), + needTitle: true, + sizeConstraint: SizeConstraint(ignoreSize: true), ), - videoOption: FilterOption( - needTitle: withAssetTitle, - sizeConstraint: const SizeConstraint(ignoreSize: true), - durationConstraint: const DurationConstraint(allowNullable: true), + videoOption: const FilterOption( + needTitle: true, + sizeConstraint: SizeConstraint(ignoreSize: true), + durationConstraint: DurationConstraint(allowNullable: true), ), // This is needed to get the modified time of the album - containsPathModified: withModifiedTime, + containsPathModified: true, createTimeCond: DateTimeCond.def().copyWith(ignore: true), updateTimeCond: updateTimeCond == null ? DateTimeCond.def().copyWith(ignore: true) @@ -40,10 +36,10 @@ class AlbumMediaRepository implements IAlbumMediaRepository { ); @override - Future> getAll() { + Future> getAll({bool withModifiedTime = false}) { return PhotoManager.getAssetPathList( hasAll: true, - filterOption: AdvancedCustomFilter(), + filterOption: _getAlbumFilter(), ).then((e) { if (_platform.isAndroid) { e.removeWhere((a) => a.isAll); @@ -59,10 +55,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository { }) async { final assetPathEntity = await AssetPathEntity.obtainPathFromProperties( id: albumId, - optionGroup: _getAlbumFilter( - withAssetTitle: true, - updateTimeCond: updateTimeCond, - ), + optionGroup: _getAlbumFilter(updateTimeCond: updateTimeCond), ); final assets = []; int pageNumber = 0, lastPageCount = 0; @@ -77,17 +70,6 @@ class AlbumMediaRepository implements IAlbumMediaRepository { } while (lastPageCount == kFetchLocalAssetsBatchSize); return Future.wait(assets.map((a) => a.toDto())); } - - @override - Future refresh( - String albumId, { - bool withModifiedTime = true, - bool withAssetCount = true, - }) => - AssetPathEntity.obtainPathFromProperties( - id: albumId, - optionGroup: _getAlbumFilter(withModifiedTime: withModifiedTime), - ).then((a) => a.toDto(withAssetCount: withAssetCount)); } extension on AssetEntity { diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 84a1e78d3a..5a78456046 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -171,7 +171,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository ], ) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) - ..orderBy([OrderingTerm.desc(_db.localAssetEntity.id)]); + ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]); return query .map((row) => row.readTable(_db.localAssetEntity).toDto()) .get(); @@ -193,25 +193,37 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository await _deleteAssets(delta.deletes); await _upsertAssets(delta.updates.map((a) => a.toLocalAsset())); + // The ugly casting below is required for now because the generated code + // casts the returned values from the platform during decoding them + // and iterating over them causes the type to be List instead of + // List await _db.batch((batch) async { - for (final asset in delta.updates) { + delta.albumAssets + .cast>() + .forEach((assetId, albumIds) { batch.deleteWhere( _db.localAlbumAssetEntity, (f) => - f.albumId.isNotIn(asset.albumIds) & f.assetId.equals(asset.id), + f.albumId.isNotIn(albumIds.cast().nonNulls) & + f.assetId.equals(assetId), ); - + }); + }); + await _db.batch((batch) async { + delta.albumAssets + .cast>() + .forEach((assetId, albumIds) { batch.insertAll( _db.localAlbumAssetEntity, - asset.albumIds.map( - (albumId) => LocalAlbumAssetEntityCompanion.insert( - assetId: asset.id, - albumId: albumId, - ), - ), + albumIds.cast().nonNulls.map( + (albumId) => LocalAlbumAssetEntityCompanion.insert( + assetId: assetId, + albumId: albumId, + ), + ), onConflict: DoNothing(), ); - } + }); }); }); } diff --git a/mobile/lib/platform/messages.dart b/mobile/lib/platform/messages.dart index 172ac1ef13..cd8cead898 100644 --- a/mobile/lib/platform/messages.dart +++ b/mobile/lib/platform/messages.dart @@ -20,7 +20,6 @@ class PlatformAsset { final int? createdAt; final int? updatedAt; final int durationInSeconds; - final List albumIds; const PlatformAsset({ required this.id, @@ -29,23 +28,26 @@ class PlatformAsset { this.createdAt, this.updatedAt, this.durationInSeconds = 0, - this.albumIds = const [], }); } class SyncDelta { - SyncDelta({ + final bool hasChanges; + final List updates; + final List deletes; + // Asset -> Album mapping + final Map> albumAssets; + + const SyncDelta({ this.hasChanges = false, this.updates = const [], this.deletes = const [], + this.albumAssets = const {}, }); - bool hasChanges; - List updates; - List deletes; } @HostApi() -abstract class ImHostService { +abstract class ImHostApi { bool shouldFullSync(); @TaskQueue(type: TaskQueueType.serialBackgroundThread) diff --git a/mobile/lib/platform/messages.g.dart b/mobile/lib/platform/messages.g.dart index decf8384eb..50871ea9b8 100644 --- a/mobile/lib/platform/messages.g.dart +++ b/mobile/lib/platform/messages.g.dart @@ -14,22 +14,21 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } - bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { return a.length == b.length && a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && - a.entries.every((MapEntry entry) => - (b as Map).containsKey(entry.key) && - _deepEquals(entry.value, b[entry.key])); + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); } return a == b; } + class PlatformAsset { PlatformAsset({ required this.id, @@ -38,7 +37,6 @@ class PlatformAsset { this.createdAt, this.updatedAt, required this.durationInSeconds, - required this.albumIds, }); String id; @@ -53,8 +51,6 @@ class PlatformAsset { int durationInSeconds; - List albumIds; - List _toList() { return [ id, @@ -63,13 +59,11 @@ class PlatformAsset { createdAt, updatedAt, durationInSeconds, - albumIds, ]; } Object encode() { - return _toList(); - } + return _toList(); } static PlatformAsset decode(Object result) { result as List; @@ -80,7 +74,6 @@ class PlatformAsset { createdAt: result[3] as int?, updatedAt: result[4] as int?, durationInSeconds: result[5]! as int, - albumIds: (result[6] as List?)!.cast(), ); } @@ -98,14 +91,16 @@ class PlatformAsset { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } class SyncDelta { SyncDelta({ - this.hasChanges = false, - this.updates = const [], - this.deletes = const [], + required this.hasChanges, + required this.updates, + required this.deletes, + required this.albumAssets, }); bool hasChanges; @@ -114,17 +109,19 @@ class SyncDelta { List deletes; + Map> albumAssets; + List _toList() { return [ hasChanges, updates, deletes, + albumAssets, ]; } Object encode() { - return _toList(); - } + return _toList(); } static SyncDelta decode(Object result) { result as List; @@ -132,6 +129,7 @@ class SyncDelta { hasChanges: result[0]! as bool, updates: (result[1] as List?)!.cast(), deletes: (result[2] as List?)!.cast(), + albumAssets: (result[3] as Map?)!.cast>(), ); } @@ -149,9 +147,11 @@ class SyncDelta { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -159,10 +159,10 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is PlatformAsset) { + } else if (value is PlatformAsset) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is SyncDelta) { + } else if (value is SyncDelta) { buffer.putUint8(130); writeValue(buffer, value.encode()); } else { @@ -173,9 +173,9 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return PlatformAsset.decode(readValue(buffer)!); - case 130: + case 130: return SyncDelta.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -183,15 +183,13 @@ class _PigeonCodec extends StandardMessageCodec { } } -class ImHostService { - /// Constructor for [ImHostService]. The [binaryMessenger] named argument is +class ImHostApi { + /// Constructor for [ImHostApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. - ImHostService( - {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + ImHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = - messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -199,10 +197,8 @@ class ImHostService { final String pigeonVar_messageChannelSuffix; Future shouldFullSync() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.shouldFullSync$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -229,10 +225,8 @@ class ImHostService { } Future getMediaChanges() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -259,10 +253,8 @@ class ImHostService { } Future checkpointSync() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.checkpointSync$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -284,10 +276,8 @@ class ImHostService { } Future clearSyncCheckpoint() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -309,16 +299,13 @@ class ImHostService { } Future> getAssetIdsForAlbum(String albumId) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = - pigeonVar_channel.send([albumId]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { diff --git a/mobile/lib/presentation/pages/feat_in_development.page.dart b/mobile/lib/presentation/pages/feat_in_development.page.dart index 6bacd9049d..1a78833d5f 100644 --- a/mobile/lib/presentation/pages/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/feat_in_development.page.dart @@ -30,7 +30,7 @@ final _features = [ _Feature( name: 'Clear Delta Checkpoint', icon: Icons.delete_rounded, - onTap: (_, ref) => ref.read(hostServiceProvider).clearSyncCheckpoint(), + onTap: (_, ref) => ref.read(hostApiProvider).clearSyncCheckpoint(), ), _Feature( name: 'Clear Local Data', diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 7b85e52829..4563e33d79 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -1,4 +1,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/platform/messages.g.dart'; -final hostServiceProvider = Provider((_) => ImHostService()); +final hostApiProvider = Provider((_) => ImHostApi()); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index ff91cb0f23..84f046bf4c 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -13,7 +13,7 @@ final deviceSyncServiceProvider = Provider( (ref) => DeviceSyncService( albumMediaRepository: ref.watch(albumMediaRepositoryProvider), localAlbumRepository: ref.watch(localAlbumRepository), - hostService: ref.watch(hostServiceProvider), + hostApi: ref.watch(hostApiProvider), ), ); diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index bd41248d03..4a3673b326 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -11,6 +11,6 @@ class MockUserService extends Mock implements UserService {} class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} -class MockHostService extends Mock implements ImHostService {} +class MockHostApi extends Mock implements ImHostApi {} class MockPlatform extends Mock implements Platform {} diff --git a/mobile/test/domain/services/device_sync_service_test.dart b/mobile/test/domain/services/device_sync_service_test.dart index 988f1d8845..417840095e 100644 --- a/mobile/test/domain/services/device_sync_service_test.dart +++ b/mobile/test/domain/services/device_sync_service_test.dart @@ -15,8 +15,8 @@ import '../service.mock.dart'; void main() { late IAlbumMediaRepository mockAlbumMediaRepo; late ILocalAlbumRepository mockLocalAlbumRepo; - late ImHostService mockHostService; - late MockPlatform mockPlatformInstance; + late ImHostApi mockHostApi; + late MockPlatform mockPlatform; late DeviceSyncService sut; Future mockTransaction(Future Function() action) => action(); @@ -24,20 +24,20 @@ void main() { setUp(() { mockAlbumMediaRepo = MockAlbumMediaRepository(); mockLocalAlbumRepo = MockLocalAlbumRepository(); - mockHostService = MockHostService(); - mockPlatformInstance = MockPlatform(); + mockHostApi = MockHostApi(); + mockPlatform = MockPlatform(); sut = DeviceSyncService( albumMediaRepository: mockAlbumMediaRepo, localAlbumRepository: mockLocalAlbumRepo, - hostService: mockHostService, - platform: mockPlatformInstance, + hostApi: mockHostApi, + platform: mockPlatform, ); registerFallbackValue(LocalAlbumStub.album1); registerFallbackValue(LocalAssetStub.image1); registerFallbackValue( - SyncDelta(hasChanges: true, updates: [], deletes: []), + SyncDelta(hasChanges: true, updates: [], deletes: [], albumAssets: {}), ); when(() => mockAlbumMediaRepo.getAll()).thenAnswer((_) async => []); @@ -82,13 +82,18 @@ void main() { when(() => mockHostService.shouldFullSync()).thenAnswer((_) async => true); when(() => mockHostService.getMediaChanges()).thenAnswer( - (_) async => SyncDelta(hasChanges: false, updates: [], deletes: []), + (_) async => SyncDelta( + hasChanges: false, + updates: [], + deletes: [], + albumAssets: {}, + ), ); when(() => mockHostService.getAssetIdsForAlbum(any())) .thenAnswer((_) async => []); when(() => mockHostService.checkpointSync()).thenAnswer((_) async => {}); - when(() => mockPlatformInstance.isAndroid).thenReturn(false); + when(() => mockPlatform.isAndroid).thenReturn(false); }); group('sync', () { @@ -119,7 +124,12 @@ void main() { when(() => mockHostService.shouldFullSync()) .thenAnswer((_) async => false); when(() => mockHostService.getMediaChanges()).thenAnswer( - (_) async => SyncDelta(hasChanges: false, updates: [], deletes: []), + (_) async => SyncDelta( + hasChanges: false, + updates: [], + deletes: [], + albumAssets: {}, + ), ); await sut.sync(); @@ -140,6 +150,9 @@ void main() { hasChanges: true, updates: [PlatformAssetStub.image1], deletes: ["deleted"], + albumAssets: { + "albumId": ["asset1", "asset2"], + }, ); final deviceAlbums = [LocalAlbumStub.album1]; @@ -149,7 +162,7 @@ void main() { .thenAnswer((_) async => delta); when(() => mockAlbumMediaRepo.getAll()) .thenAnswer((_) async => deviceAlbums); - when(() => mockPlatformInstance.isAndroid).thenReturn(false); + when(() => mockPlatform.isAndroid).thenReturn(false); await sut.sync(); @@ -172,6 +185,9 @@ void main() { hasChanges: true, updates: [PlatformAssetStub.image1], deletes: ["deleted"], + albumAssets: { + "dbAlbumId": ["asset1", "asset2"], + }, ); final deviceAlbums = [LocalAlbumStub.album1]; final dbAlbums = [LocalAlbumStub.album2.copyWith(id: "dbAlbumId")]; @@ -185,7 +201,7 @@ void main() { .thenAnswer((_) async => deviceAlbums); when(() => mockLocalAlbumRepo.getAll()) .thenAnswer((_) async => dbAlbums); - when(() => mockPlatformInstance.isAndroid).thenReturn(true); + when(() => mockPlatform.isAndroid).thenReturn(true); when(() => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id)) .thenAnswer((_) async => assetIdsForDbAlbum); @@ -197,7 +213,7 @@ void main() { () => mockAlbumMediaRepo.getAll(), () => mockLocalAlbumRepo.updateAll(deviceAlbums), () => mockLocalAlbumRepo.processDelta(delta), - () => mockPlatformInstance.isAndroid, + () => mockPlatform.isAndroid, () => mockLocalAlbumRepo.getAll(), () => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id), () => mockLocalAlbumRepo.syncAlbumDeletes( diff --git a/mobile/test/fixtures/platform_asset.stub.dart b/mobile/test/fixtures/platform_asset.stub.dart index 823113840b..a67f94431d 100644 --- a/mobile/test/fixtures/platform_asset.stub.dart +++ b/mobile/test/fixtures/platform_asset.stub.dart @@ -8,7 +8,6 @@ abstract final class PlatformAssetStub { createdAt: DateTime(2024, 1, 1).millisecondsSinceEpoch, updatedAt: DateTime(2024, 1, 1).millisecondsSinceEpoch, durationInSeconds: 0, - albumIds: ["album1"], ); static PlatformAsset get video1 => PlatformAsset( @@ -18,6 +17,5 @@ abstract final class PlatformAssetStub { createdAt: DateTime(2024, 1, 2).millisecondsSinceEpoch, updatedAt: DateTime(2024, 1, 2).millisecondsSinceEpoch, durationInSeconds: 120, - albumIds: ["album1"], ); }