From 5ea136cd3280bfa790cf520283142d2d55bd7fd5 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sat, 24 May 2025 00:58:55 +0530 Subject: [PATCH] remove photo_manager dep for sync --- .../app/alextran/immich/sync/Messages.g.kt | 38 +++ .../alextran/immich/sync/MessagesImpl26.kt | 4 - .../alextran/immich/sync/MessagesImpl30.kt | 8 +- .../alextran/immich/sync/MessagesImplBase.kt | 263 ++++++++++-------- mobile/ios/Runner/Sync/Messages.g.swift | 38 +++ mobile/ios/Runner/Sync/MessagesImpl.swift | 32 +++ .../interfaces/album_media.interface.dart | 15 - .../interfaces/local_album.interface.dart | 7 +- .../domain/services/device_sync.service.dart | 67 +++-- .../repositories/album_media.repository.dart | 76 ----- .../repositories/local_album.repository.dart | 38 +-- mobile/lib/platform/native_sync_api.g.dart | 56 ++++ .../infrastructure/album.provider.dart | 5 - .../infrastructure/sync.provider.dart | 1 - mobile/pigeon/native_sync_api.dart | 6 + 15 files changed, 381 insertions(+), 273 deletions(-) delete mode 100644 mobile/lib/domain/interfaces/album_media.interface.dart delete mode 100644 mobile/lib/infrastructure/repositories/album_media.repository.dart diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt index ca50f2e67e..73c804e972 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -245,6 +245,8 @@ interface NativeSyncApi { fun clearSyncCheckpoint() fun getAssetIdsForAlbum(albumId: String): List fun getAlbums(): List + fun getAssetsCountSince(albumId: String, timestamp: Long): Long + fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List companion object { /** The codec used by NativeSyncApi. */ @@ -350,6 +352,42 @@ interface NativeSyncApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val albumIdArg = args[0] as String + val timestampArg = args[1] as Long + val wrapped: List = try { + listOf(api.getAssetsCountSince(albumIdArg, timestampArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val albumIdArg = args[0] as String + val updatedTimeCondArg = args[1] as Long? + val wrapped: List = try { + listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg)) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt index 697e1be66d..5deacc30db 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl26.kt @@ -18,10 +18,6 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na // No-op for Android 10 and below } - override fun getAssetIdsForAlbum(albumId: String): List { - throw IllegalStateException("Method not supported on this Android version.") - } - override fun getMediaChanges(): SyncDelta { throw IllegalStateException("Method not supported on this Android version.") } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt index 85542c58a2..2095e6be14 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImpl30.kt @@ -47,12 +47,6 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na } } - override fun getAssetIdsForAlbum(albumId: String): List = getAssets( - MediaStore.VOLUME_EXTERNAL, - "${MediaStore.Files.FileColumns.BUCKET_ID} = ? AND $MEDIA_SELECTION", - arrayOf(albumId, *MEDIA_SELECTION_ARGS) - ).mapNotNull { (it as? AssetResult.ValidAsset)?.asset?.id }.toList() - override fun getMediaChanges(): SyncDelta { val genMap = getSavedGenerationMap() val currentVolumes = MediaStore.getExternalVolumeNames(ctx) @@ -78,7 +72,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na storedGen.toString() ) - getAssets(volume, selection, selectionArgs).forEach { + getAssets(getCursor(volume, selection, selectionArgs)).forEach { when (it) { is AssetResult.ValidAsset -> { changed.add(it.asset) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index b8c8ac36d0..10eb268a2c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -2,141 +2,176 @@ package app.alextran.immich.sync import android.annotation.SuppressLint import android.content.Context +import android.database.Cursor import android.provider.MediaStore import java.io.File sealed class AssetResult { - data class ValidAsset(val asset: ImAsset, val albumId: String) : AssetResult() - data class InvalidAsset(val assetId: String) : AssetResult() + data class ValidAsset(val asset: ImAsset, val albumId: String) : AssetResult() + data class InvalidAsset(val assetId: String) : AssetResult() } +@SuppressLint("InlinedApi") open class NativeSyncApiImplBase(context: Context) { - private val ctx: Context = context.applicationContext + private val ctx: Context = context.applicationContext - companion object { - const val MEDIA_SELECTION = - "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" - val MEDIA_SELECTION_ARGS = arrayOf( - MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() - ) - } + companion object { + const val MEDIA_SELECTION = + "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" + val MEDIA_SELECTION_ARGS = arrayOf( + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() + ) + const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)" + val ASSET_PROJECTION = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.DATE_TAKEN, + MediaStore.MediaColumns.DATE_ADDED, + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.MediaColumns.BUCKET_ID, + MediaStore.MediaColumns.DURATION + ) + } - protected fun getAssets( - volume: String, - selection: String, - selectionArgs: Array, - ): Sequence { - val projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATA, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.DATE_TAKEN, - MediaStore.MediaColumns.DATE_ADDED, - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.Files.FileColumns.MEDIA_TYPE, - MediaStore.MediaColumns.BUCKET_ID, - MediaStore.MediaColumns.DURATION - ) + protected fun getCursor( + volume: String, + selection: String, + selectionArgs: Array, + projection: Array = ASSET_PROJECTION, + sortOrder: String? = null + ): Cursor? = ctx.contentResolver.query( + MediaStore.Files.getContentUri(volume), + projection, + selection, + selectionArgs, + sortOrder, + ) - return sequence { - ctx.contentResolver.query( - MediaStore.Files.getContentUri(volume), - projection, - selection, - selectionArgs, - null - )?.use { cursor -> - val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) - val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) - val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) - val dateTakenColumn = - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) - val dateAddedColumn = - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) - val dateModifiedColumn = - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) - val mediaTypeColumn = - cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) - val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) - val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION) + protected fun getAssets(cursor: Cursor?): Sequence { + return sequence { + cursor?.use { c -> + val idColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val dataColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) + val nameColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) + val dateTakenColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) + val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) + val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) + val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION) - while (cursor.moveToNext()) { - val id = cursor.getLong(idColumn).toString() - val path = cursor.getString(dataColumn) - if (path.isNullOrBlank() || !File(path).exists()) { - yield(AssetResult.InvalidAsset(id)) - continue - } + while (c.moveToNext()) { + val id = c.getLong(idColumn).toString() - val mediaType = cursor.getInt(mediaTypeColumn) - val name = cursor.getString(nameColumn) - // Date taken is milliseconds since epoch, Date added is seconds since epoch - val createdAt = (cursor.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) - ?: cursor.getLong(dateAddedColumn) - // Date modified is seconds since epoch - val modifiedAt = cursor.getLong(dateModifiedColumn) - // Duration is milliseconds - val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 - else cursor.getLong(durationColumn) / 1000 - val bucketId = cursor.getString(bucketIdColumn) + val path = c.getString(dataColumn) + if (path.isNullOrBlank() || !File(path).exists()) { + yield(AssetResult.InvalidAsset(id)) + continue + } - yield( - AssetResult.ValidAsset( - ImAsset(id, name, mediaType.toLong(), createdAt, modifiedAt, duration), - bucketId - ) - ) - } - } + val mediaType = c.getInt(mediaTypeColumn) + val name = c.getString(nameColumn) + // Date taken is milliseconds since epoch, Date added is seconds since epoch + val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) + ?: c.getLong(dateAddedColumn) + // Date modified is seconds since epoch + val modifiedAt = c.getLong(dateModifiedColumn) + // Duration is milliseconds + val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 + else c.getLong(durationColumn) / 1000 + val bucketId = c.getString(bucketIdColumn) + + val asset = ImAsset(id, name, mediaType.toLong(), createdAt, modifiedAt, duration) + yield(AssetResult.ValidAsset(asset, bucketId)) } + } } + } - @SuppressLint("InlinedApi") - fun getAlbums(): List { - val albums = mutableListOf() - val albumsCount = mutableMapOf() + fun getAlbums(): List { + val albums = mutableListOf() + val albumsCount = mutableMapOf() - val projection = arrayOf( - MediaStore.Files.FileColumns.BUCKET_ID, - MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME, - MediaStore.Files.FileColumns.DATE_MODIFIED, - ) - val selection = - "(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION" + val projection = arrayOf( + MediaStore.Files.FileColumns.BUCKET_ID, + MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_MODIFIED, + ) + val selection = + "(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION" - ctx.contentResolver.query( - MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), - projection, - selection, - MEDIA_SELECTION_ARGS, - "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" - )?.use { cursor -> - val bucketIdColumn = - cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID) - val bucketNameColumn = - cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME) - val dateModified = - cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED) + getCursor( + MediaStore.VOLUME_EXTERNAL, + selection, + MEDIA_SELECTION_ARGS, + projection, + "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" + )?.use { cursor -> + val bucketIdColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_ID) + val bucketNameColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME) + val dateModified = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED) - while (cursor.moveToNext()) { - val id = cursor.getString(bucketIdColumn) - val count = albumsCount.getOrDefault(id, 0) - if (count != 0) { - albumsCount[id] = count + 1 - continue - } + while (cursor.moveToNext()) { + val id = cursor.getString(bucketIdColumn) - val name = cursor.getString(bucketNameColumn) - val updatedAt = cursor.getLong(dateModified) - albums.add(ImAlbum(id, name, updatedAt, false, 0)) - albumsCount[id] = 1 - } + val count = albumsCount.getOrDefault(id, 0) + if (count != 0) { + albumsCount[id] = count + 1 + continue } - return albums.map { album -> - val count = albumsCount[album.id] ?: 0 - album.copy(assetCount = count.toLong()) - }.sortedBy { it.id } + val name = cursor.getString(bucketNameColumn) + val updatedAt = cursor.getLong(dateModified) + albums.add(ImAlbum(id, name, updatedAt, false, 0)) + albumsCount[id] = 1 + } } + + return albums.map { it.copy(assetCount = albumsCount[it.id]?.toLong() ?: 0) } + .sortedBy { it.id } + } + + fun getAssetIdsForAlbum(albumId: String): List { + val projection = arrayOf(MediaStore.MediaColumns._ID) + + return getCursor( + MediaStore.VOLUME_EXTERNAL, + "$BUCKET_SELECTION AND $MEDIA_SELECTION", + arrayOf(albumId, *MEDIA_SELECTION_ARGS), + projection + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + generateSequence { + if (cursor.moveToNext()) cursor.getLong(idColumn).toString() else null + }.toList() + } ?: emptyList() + } + + fun getAssetsCountSince(albumId: String, timestamp: Long): Long = + getCursor( + MediaStore.VOLUME_EXTERNAL, + "$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION", + arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS), + )?.use { cursor -> cursor.count.toLong() } ?: 0L + + + fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List { + var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION" + val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS) + + if (updatedTimeCond != null) { + selection += " AND (${MediaStore.Files.FileColumns.DATE_MODIFIED} > ? OR ${MediaStore.Files.FileColumns.DATE_ADDED} > ?)" + selectionArgs.addAll(listOf(updatedTimeCond.toString(), updatedTimeCond.toString())) + } + + return getAssets(getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray())) + .mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset } + .toList() + } } diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index 5d911855df..3a3d79aa81 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -305,6 +305,8 @@ protocol NativeSyncApi { func clearSyncCheckpoint() throws func getAssetIdsForAlbum(albumId: String) throws -> [String] func getAlbums() throws -> [ImAlbum] + func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 + func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [ImAsset] } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -404,5 +406,41 @@ class NativeSyncApiSetup { } else { getAlbumsChannel.setMessageHandler(nil) } + let getAssetsCountSinceChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getAssetsCountSinceChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let albumIdArg = args[0] as! String + let timestampArg = args[1] as! Int64 + do { + let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAssetsCountSinceChannel.setMessageHandler(nil) + } + let getAssetsForAlbumChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + getAssetsForAlbumChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let albumIdArg = args[0] as! String + let updatedTimeCondArg: Int64? = nilOrValue(args[1]) + do { + let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAssetsForAlbumChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index ebc4fca8e9..042705f386 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -199,4 +199,36 @@ class NativeSyncApiImpl: NativeSyncApi { } return ids } + + func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 { + let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) + guard let album = collections.firstObject else { + return 0 + } + + let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) + let options = PHFetchOptions() + options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) + let assets = PHAsset.fetchAssets(in: album, options: options) + return Int64(assets.count) + } + + func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [ImAsset] { + let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) + guard let album = collections.firstObject else { + return [] + } + + let options = PHFetchOptions() + if(updatedTimeCond != nil) { + let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) + options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) + } + let result = PHAsset.fetchAssets(in: album, options: options) + var assets: [ImAsset] = [] + result.enumerateObjects { (asset, _, _) in + assets.append(asset.toImAsset()) + } + return assets + } } diff --git a/mobile/lib/domain/interfaces/album_media.interface.dart b/mobile/lib/domain/interfaces/album_media.interface.dart deleted file mode 100644 index 07640b24b4..0000000000 --- a/mobile/lib/domain/interfaces/album_media.interface.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; - -abstract interface class IAlbumMediaRepository { - Future> getAssetsForAlbum( - String albumId, { - DateTimeFilter? updateTimeCond, - }); -} - -class DateTimeFilter { - final DateTime min; - final DateTime max; - - const DateTimeFilter({required this.min, required this.max}); -} diff --git a/mobile/lib/domain/interfaces/local_album.interface.dart b/mobile/lib/domain/interfaces/local_album.interface.dart index 48c3a365aa..35cfad4455 100644 --- a/mobile/lib/domain/interfaces/local_album.interface.dart +++ b/mobile/lib/domain/interfaces/local_album.interface.dart @@ -1,7 +1,6 @@ import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/local_album.model.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; abstract interface class ILocalAlbumRepository implements IDatabaseRepository { Future> getAll({SortLocalAlbumsBy? sortBy}); @@ -20,7 +19,11 @@ abstract interface class ILocalAlbumRepository implements IDatabaseRepository { Future delete(String albumId); - Future processDelta(SyncDelta delta); + Future processDelta({ + required List updates, + required List deletes, + required Map> assetAlbums, + }); Future syncAlbumDeletes( String albumId, diff --git a/mobile/lib/domain/services/device_sync.service.dart b/mobile/lib/domain/services/device_sync.service.dart index 1c8182aef4..b530348db2 100644 --- a/mobile/lib/domain/services/device_sync.service.dart +++ b/mobile/lib/domain/services/device_sync.service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -import 'package:immich_mobile/domain/interfaces/album_media.interface.dart'; import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/local_album.model.dart'; @@ -15,7 +14,6 @@ import 'package:logging/logging.dart'; import 'package:platform/platform.dart'; class DeviceSyncService { - final IAlbumMediaRepository _albumMediaRepository; final ILocalAlbumRepository _localAlbumRepository; final NativeSyncApi _nativeSyncApi; final Platform _platform; @@ -23,13 +21,11 @@ class DeviceSyncService { final Logger _log = Logger("DeviceSyncService"); DeviceSyncService({ - required IAlbumMediaRepository albumMediaRepository, required ILocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi, required StoreService storeService, Platform? platform, - }) : _albumMediaRepository = albumMediaRepository, - _localAlbumRepository = localAlbumRepository, + }) : _localAlbumRepository = localAlbumRepository, _nativeSyncApi = nativeSyncApi, _storeService = storeService, _platform = platform ?? const LocalPlatform(); @@ -58,7 +54,11 @@ class DeviceSyncService { final deviceAlbums = await _nativeSyncApi.getAlbums(); await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); - await _localAlbumRepository.processDelta(delta); + await _localAlbumRepository.processDelta( + updates: delta.updates.toLocalAssets(), + deletes: delta.deletes, + assetAlbums: delta.assetAlbums, + ); final dbAlbums = await _localAlbumRepository.getAll(); // On Android, we need to sync all albums since it is not possible to @@ -135,10 +135,13 @@ class DeviceSyncService { _log.fine("Adding device album ${album.name}"); final assets = album.assetCount > 0 - ? await _albumMediaRepository.getAssetsForAlbum(album.id) - : []; + ? await _nativeSyncApi.getAssetsForAlbum(album.id) + : []; - await _localAlbumRepository.upsert(album, toUpsert: assets); + await _localAlbumRepository.upsert( + album, + toUpsert: assets.toLocalAssets(), + ); _log.fine("Successfully added device album ${album.name}"); } catch (e, s) { _log.warning("Error while adding device album", e, s); @@ -198,17 +201,13 @@ class DeviceSyncService { return false; } - // Get all assets that are modified after the last known modifiedTime - final newAssets = await _albumMediaRepository.getAssetsForAlbum( - deviceAlbum.id, - updateTimeCond: DateTimeFilter( - min: dbAlbum.updatedAt.add(const Duration(seconds: 1)), - max: deviceAlbum.updatedAt, - ), - ); + final updatedTime = + (dbAlbum.updatedAt.millisecondsSinceEpoch ~/ 1000) + 1; + final newAssetsCount = + await _nativeSyncApi.getAssetsCountSince(deviceAlbum.id, updatedTime); // Early return if no new assets were found - if (newAssets.isEmpty) { + if (newAssetsCount == 0) { _log.fine( "No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}", ); @@ -216,14 +215,19 @@ class DeviceSyncService { } // Check whether there is only addition or if there has been deletions - if (deviceAlbum.assetCount != dbAlbum.assetCount + newAssets.length) { + if (deviceAlbum.assetCount != dbAlbum.assetCount + newAssetsCount) { _log.fine("Local album has modifications. Proceeding to full sync"); return false; } + final newAssets = await _nativeSyncApi.getAssetsForAlbum( + deviceAlbum.id, + updatedTimeCond: updatedTime, + ); + await _localAlbumRepository.upsert( deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), - toUpsert: newAssets, + toUpsert: newAssets.toLocalAssets(), ); return true; @@ -239,7 +243,9 @@ class DeviceSyncService { Future fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async { try { final assetsInDevice = deviceAlbum.assetCount > 0 - ? await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id) + ? await _nativeSyncApi + .getAssetsForAlbum(deviceAlbum.id) + .then((a) => a.toLocalAssets()) : []; final assetsInDb = dbAlbum.assetCount > 0 ? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id) @@ -348,3 +354,22 @@ extension on Iterable { ).toList(); } } + +extension on Iterable { + List toLocalAssets() { + return map( + (e) => LocalAsset( + id: e.id, + name: e.name, + type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other, + createdAt: e.createdAt == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000), + updatedAt: e.updatedAt == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000), + durationInSeconds: e.durationInSeconds, + ), + ).toList(); + } +} diff --git a/mobile/lib/infrastructure/repositories/album_media.repository.dart b/mobile/lib/infrastructure/repositories/album_media.repository.dart deleted file mode 100644 index 88e51993e1..0000000000 --- a/mobile/lib/infrastructure/repositories/album_media.repository.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' - as asset; -import 'package:photo_manager/photo_manager.dart'; - -class AlbumMediaRepository implements IAlbumMediaRepository { - const AlbumMediaRepository(); - - PMFilter _getAlbumFilter({ - DateTimeFilter? createdTimeCond, - DateTimeFilter? updateTimeCond, - }) => - FilterOptionGroup( - imageOption: const FilterOption( - // needTitle is expected to be slow on iOS but is required to fetch the asset title - needTitle: true, - sizeConstraint: SizeConstraint(ignoreSize: 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: true, - createTimeCond: createdTimeCond == null - ? DateTimeCond.def().copyWith(ignore: true) - : DateTimeCond(min: createdTimeCond.min, max: createdTimeCond.max), - updateTimeCond: updateTimeCond == null - ? DateTimeCond.def().copyWith(ignore: true) - : DateTimeCond(min: updateTimeCond.min, max: updateTimeCond.max), - orders: [], - ); - - @override - Future> getAssetsForAlbum( - String albumId, { - DateTimeFilter? updateTimeCond, - }) async { - final assetPathEntity = await AssetPathEntity.obtainPathFromProperties( - id: albumId, - optionGroup: _getAlbumFilter(updateTimeCond: updateTimeCond), - ); - final assets = []; - int pageNumber = 0, lastPageCount = 0; - do { - final page = await assetPathEntity.getAssetListPaged( - page: pageNumber, - size: kFetchLocalAssetsBatchSize, - ); - assets.addAll(page); - lastPageCount = page.length; - pageNumber++; - } while (lastPageCount == kFetchLocalAssetsBatchSize); - return Future.wait(assets.map((a) => a.toDto())); - } -} - -extension on AssetEntity { - Future toDto() async => asset.LocalAsset( - id: id, - name: title ?? await titleAsync, - type: switch (type) { - AssetType.other => asset.AssetType.other, - AssetType.image => asset.AssetType.image, - AssetType.video => asset.AssetType.video, - AssetType.audio => asset.AssetType.audio, - }, - createdAt: createDateTime, - updatedAt: modifiedDateTime, - width: width, - height: height, - durationInSeconds: duration, - ); -} diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 2af45f7cd4..650b7a1aab 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -6,7 +6,6 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:platform/platform.dart'; class DriftLocalAlbumRepository extends DriftDatabaseRepository @@ -187,19 +186,21 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository } @override - Future processDelta(SyncDelta delta) { + Future processDelta({ + required List updates, + required List deletes, + required Map> assetAlbums, + }) { return _db.transaction(() async { - await _deleteAssets(delta.deletes); + await _deleteAssets(deletes); - await _upsertAssets(delta.updates.map((a) => a.toLocalAsset())); + await _upsertAssets(updates); // 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 { - delta.assetAlbums - .cast>() - .forEach((assetId, albumIds) { + assetAlbums.cast>().forEach((assetId, albumIds) { batch.deleteWhere( _db.localAlbumAssetEntity, (f) => @@ -209,9 +210,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository }); }); await _db.batch((batch) async { - delta.assetAlbums - .cast>() - .forEach((assetId, albumIds) { + assetAlbums.cast>().forEach((assetId, albumIds) { batch.insertAll( _db.localAlbumAssetEntity, albumIds.cast().nonNulls.map( @@ -339,24 +338,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository } } -extension on ImAsset { - LocalAsset toLocalAsset() { - return LocalAsset( - id: id, - name: name, - type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, - createdAt: createdAt == null - ? DateTime.now() - : DateTime.fromMillisecondsSinceEpoch(createdAt! * 1000), - updatedAt: updatedAt == null - ? DateTime.now() - : DateTime.fromMillisecondsSinceEpoch(updatedAt! * 1000), - durationInSeconds: durationInSeconds, - ); - } -} - -extension LocalAlbumEntityX on LocalAlbumEntityData { +extension on LocalAlbumEntityData { LocalAlbum toDto({int assetCount = 0}) { return LocalAlbum( id: id, diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 8787bf08ab..9e8e662181 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -419,4 +419,60 @@ class NativeSyncApi { return (pigeonVar_replyList[0] as List?)!.cast(); } } + + Future getAssetsCountSince(String albumId, int timestamp) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId, timestamp]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + Future> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId, updatedTimeCond]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } } diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 1f65af43a4..cb4aadb8a7 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -1,13 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/interfaces/album_media.interface.dart'; import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; -import 'package:immich_mobile/infrastructure/repositories/album_media.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -final albumMediaRepositoryProvider = - Provider((ref) => const AlbumMediaRepository()); - final localAlbumRepository = Provider( (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), ); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 54a53823c1..a7c0b76da5 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -28,7 +28,6 @@ final syncStreamRepositoryProvider = Provider( final deviceSyncServiceProvider = Provider( (ref) => DeviceSyncService( - albumMediaRepository: ref.watch(albumMediaRepositoryProvider), localAlbumRepository: ref.watch(localAlbumRepository), nativeSyncApi: ref.watch(nativeSyncApiProvider), storeService: ref.watch(storeServiceProvider), diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index 891291ceca..23e1b956b8 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -80,4 +80,10 @@ abstract class NativeSyncApi { @TaskQueue(type: TaskQueueType.serialBackgroundThread) List getAlbums(); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + int getAssetsCountSince(String albumId, int timestamp); + + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List getAssetsForAlbum(String albumId, {int? updatedTimeCond}); }