sync fixes

This commit is contained in:
shenlong-tanwen 2025-05-14 19:25:34 +05:30
parent 61316d94a9
commit f36049a696
20 changed files with 198 additions and 209 deletions

View File

@ -2,12 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import app.alextran.immich.platform.MessagesImpl import app.alextran.immich.platform.ImHostApiImpl
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(BackgroundServicePlugin())
ImHostService.setUp(flutterEngine.dartExecutor.binaryMessenger, MessagesImpl(this)) ImHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ImHostApiImpl(this))
} }
} }

View File

@ -80,6 +80,7 @@ class MediaManager(context: Context) {
val currentVolumes = MediaStore.getExternalVolumeNames(ctx) val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
val changed = mutableListOf<PlatformAsset>() val changed = mutableListOf<PlatformAsset>()
val deleted = mutableListOf<String>() val deleted = mutableListOf<String>()
val albumAssets = mutableMapOf<String, List<String>>()
var hasChanges = genMap.keys != currentVolumes var hasChanges = genMap.keys != currentVolumes
for (volume in currentVolumes) { for (volume in currentVolumes) {
@ -160,15 +161,15 @@ class MediaManager(context: Context) {
mediaType.toLong(), mediaType.toLong(),
createdAt, createdAt,
modifiedAt, modifiedAt,
duration, duration
listOf(bucketId)
) )
) )
albumAssets.put(id, listOf(bucketId))
} }
} }
} }
// Unmounted volumes are handled in dart when the album is removed // Unmounted volumes are handled in dart when the album is removed
return SyncDelta(hasChanges, changed, deleted) return SyncDelta(hasChanges, changed, deleted, albumAssets)
} }
} }

View File

@ -84,8 +84,7 @@ data class PlatformAsset (
val type: Long, val type: Long,
val createdAt: Long? = null, val createdAt: Long? = null,
val updatedAt: Long? = null, val updatedAt: Long? = null,
val durationInSeconds: Long, val durationInSeconds: Long
val albumIds: List<String>
) )
{ {
companion object { companion object {
@ -96,8 +95,7 @@ data class PlatformAsset (
val createdAt = pigeonVar_list[3] as Long? val createdAt = pigeonVar_list[3] as Long?
val updatedAt = pigeonVar_list[4] as Long? val updatedAt = pigeonVar_list[4] as Long?
val durationInSeconds = pigeonVar_list[5] as Long val durationInSeconds = pigeonVar_list[5] as Long
val albumIds = pigeonVar_list[6] as List<String> return PlatformAsset(id, name, type, createdAt, updatedAt, durationInSeconds)
return PlatformAsset(id, name, type, createdAt, updatedAt, durationInSeconds, albumIds)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
@ -108,7 +106,6 @@ data class PlatformAsset (
createdAt, createdAt,
updatedAt, updatedAt,
durationInSeconds, durationInSeconds,
albumIds,
) )
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -127,7 +124,8 @@ data class PlatformAsset (
data class SyncDelta ( data class SyncDelta (
val hasChanges: Boolean, val hasChanges: Boolean,
val updates: List<PlatformAsset>, val updates: List<PlatformAsset>,
val deletes: List<String> val deletes: List<String>,
val albumAssets: Map<String, List<String>>
) )
{ {
companion object { companion object {
@ -135,7 +133,8 @@ data class SyncDelta (
val hasChanges = pigeonVar_list[0] as Boolean val hasChanges = pigeonVar_list[0] as Boolean
val updates = pigeonVar_list[1] as List<PlatformAsset> val updates = pigeonVar_list[1] as List<PlatformAsset>
val deletes = pigeonVar_list[2] as List<String> val deletes = pigeonVar_list[2] as List<String>
return SyncDelta(hasChanges, updates, deletes) val albumAssets = pigeonVar_list[3] as Map<String, List<String>>
return SyncDelta(hasChanges, updates, deletes, albumAssets)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
@ -143,6 +142,7 @@ data class SyncDelta (
hasChanges, hasChanges,
updates, updates,
deletes, deletes,
albumAssets,
) )
} }
override fun equals(other: Any?): Boolean { 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. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ImHostService { interface ImHostApi {
fun shouldFullSync(): Boolean fun shouldFullSync(): Boolean
fun getMediaChanges(): SyncDelta fun getMediaChanges(): SyncDelta
fun checkpointSync() fun checkpointSync()
@ -196,17 +196,17 @@ interface ImHostService {
fun getAssetIdsForAlbum(albumId: String): List<String> fun getAssetIdsForAlbum(albumId: String): List<String>
companion object { companion object {
/** The codec used by ImHostService. */ /** The codec used by ImHostApi. */
val codec: MessageCodec<Any?> by lazy { val codec: MessageCodec<Any?> by lazy {
MessagesPigeonCodec() 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 @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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val taskQueue = binaryMessenger.makeBackgroundTaskQueue() val taskQueue = binaryMessenger.makeBackgroundTaskQueue()
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.shouldFullSync$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
@ -221,7 +221,7 @@ interface ImHostService {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
@ -236,7 +236,7 @@ interface ImHostService {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.checkpointSync$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
@ -252,7 +252,7 @@ interface ImHostService {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.clearSyncCheckpoint$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.clearSyncCheckpoint$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
channel.setMessageHandler { _, reply -> channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
@ -268,7 +268,7 @@ interface ImHostService {
} }
} }
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>

View File

@ -1,12 +1,12 @@
package app.alextran.immich.platform package app.alextran.immich.platform
import ImHostService import ImHostApi
import SyncDelta import SyncDelta
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.ext.SdkExtensions import android.os.ext.SdkExtensions
class MessagesImpl(context: Context) : ImHostService { class ImHostApiImpl(context: Context) : ImHostApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private val mediaManager: MediaManager = MediaManager(ctx) private val mediaManager: MediaManager = MediaManager(ctx)

View File

@ -25,7 +25,7 @@ import UIKit
// Register pigeon handler // Register pigeon handler
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController 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 BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {

View File

@ -97,7 +97,7 @@ class MediaManager {
let currentToken = PHPhotoLibrary.shared().currentChangeToken let currentToken = PHPhotoLibrary.shared().currentChangeToken
if storedToken == currentToken { if storedToken == currentToken {
return SyncDelta(hasChanges: false, updates: [], deletes: []) return SyncDelta(hasChanges: false, updates: [], deletes: [], albumAssets: [:])
} }
do { do {
@ -110,6 +110,7 @@ class MediaManager {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue } if (updated.isEmpty) { continue }
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil) let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil)
@ -117,7 +118,7 @@ class MediaManager {
let asset = result.object(at: i) let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes // 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))) { if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue continue
} }
@ -136,37 +137,33 @@ class MediaManager {
createdAt: createdAt.map { Int64($0) }, createdAt: createdAt.map { Int64($0) },
updatedAt: updatedAt.map { Int64($0) }, updatedAt: updatedAt.map { Int64($0) },
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
albumIds: self._getAlbumIds(forAsset: asset)
)) ))
updatedAssets.insert(domainAsset) 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, *) private func buildAlbumAssetsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
func _getAlbumIds(forAsset: PHAsset) -> [String] { var albumAssets: [String: [String]] = [:]
var albumIds: [String] = []
let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
albumTypes.forEach { type in albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in collections.enumerateObjects { (album, _, _) in
var options = PHFetchOptions() let options = PHFetchOptions()
options.fetchLimit = 1 options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
options.predicate = NSPredicate(format: "localIdentifier == %@", forAsset.localIdentifier)
let result = PHAsset.fetchAssets(in: album, options: options) let result = PHAsset.fetchAssets(in: album, options: options)
if(result.count == 1) { for i in 0..<result.count {
albumIds.append(album.localIdentifier) let asset = result.object(at: i)
albumAssets[asset.localIdentifier, default: []].append(album.localIdentifier)
} }
} }
} }
return albumIds return albumAssets
} }
} }

View File

@ -136,7 +136,6 @@ struct PlatformAsset: Hashable {
var createdAt: Int64? = nil var createdAt: Int64? = nil
var updatedAt: Int64? = nil var updatedAt: Int64? = nil
var durationInSeconds: Int64 var durationInSeconds: Int64
var albumIds: [String]
// swift-format-ignore: AlwaysUseLowerCamelCase // swift-format-ignore: AlwaysUseLowerCamelCase
@ -147,7 +146,6 @@ struct PlatformAsset: Hashable {
let createdAt: Int64? = nilOrValue(pigeonVar_list[3]) let createdAt: Int64? = nilOrValue(pigeonVar_list[3])
let updatedAt: Int64? = nilOrValue(pigeonVar_list[4]) let updatedAt: Int64? = nilOrValue(pigeonVar_list[4])
let durationInSeconds = pigeonVar_list[5] as! Int64 let durationInSeconds = pigeonVar_list[5] as! Int64
let albumIds = pigeonVar_list[6] as! [String]
return PlatformAsset( return PlatformAsset(
id: id, id: id,
@ -155,8 +153,7 @@ struct PlatformAsset: Hashable {
type: type, type: type,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds
albumIds: albumIds
) )
} }
func toList() -> [Any?] { func toList() -> [Any?] {
@ -167,7 +164,6 @@ struct PlatformAsset: Hashable {
createdAt, createdAt,
updatedAt, updatedAt,
durationInSeconds, durationInSeconds,
albumIds,
] ]
} }
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
@ -182,6 +178,7 @@ struct SyncDelta: Hashable {
var hasChanges: Bool var hasChanges: Bool
var updates: [PlatformAsset] var updates: [PlatformAsset]
var deletes: [String] var deletes: [String]
var albumAssets: [String: [String]]
// swift-format-ignore: AlwaysUseLowerCamelCase // swift-format-ignore: AlwaysUseLowerCamelCase
@ -189,11 +186,13 @@ struct SyncDelta: Hashable {
let hasChanges = pigeonVar_list[0] as! Bool let hasChanges = pigeonVar_list[0] as! Bool
let updates = pigeonVar_list[1] as! [PlatformAsset] let updates = pigeonVar_list[1] as! [PlatformAsset]
let deletes = pigeonVar_list[2] as! [String] let deletes = pigeonVar_list[2] as! [String]
let albumAssets = pigeonVar_list[3] as! [String: [String]]
return SyncDelta( return SyncDelta(
hasChanges: hasChanges, hasChanges: hasChanges,
updates: updates, updates: updates,
deletes: deletes deletes: deletes,
albumAssets: albumAssets
) )
} }
func toList() -> [Any?] { func toList() -> [Any?] {
@ -201,6 +200,7 @@ struct SyncDelta: Hashable {
hasChanges, hasChanges,
updates, updates,
deletes, deletes,
albumAssets,
] ]
} }
static func == (lhs: SyncDelta, rhs: SyncDelta) -> Bool { 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. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ImHostService { protocol ImHostApi {
func shouldFullSync() throws -> Bool func shouldFullSync() throws -> Bool
func getMediaChanges() throws -> SyncDelta func getMediaChanges() throws -> SyncDelta
func checkpointSync() throws func checkpointSync() throws
@ -261,17 +261,17 @@ protocol ImHostService {
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class ImHostServiceSetup { class ImHostApiSetup {
static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared }
/// Sets up an instance of `ImHostService` to handle messages through the `binaryMessenger`. /// Sets up an instance of `ImHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImHostService?, messageChannelSuffix: String = "") { static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
#if os(iOS) #if os(iOS)
let taskQueue = binaryMessenger.makeBackgroundTaskQueue?() let taskQueue = binaryMessenger.makeBackgroundTaskQueue?()
#else #else
let taskQueue: FlutterTaskQueue? = nil let taskQueue: FlutterTaskQueue? = nil
#endif #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 { if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in shouldFullSyncChannel.setMessageHandler { _, reply in
do { do {
@ -285,8 +285,8 @@ class ImHostServiceSetup {
shouldFullSyncChannel.setMessageHandler(nil) shouldFullSyncChannel.setMessageHandler(nil)
} }
let getMediaChangesChannel = taskQueue == 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.ImHostApi.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, taskQueue: taskQueue)
if let api = api { if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in getMediaChangesChannel.setMessageHandler { _, reply in
do { do {
@ -299,7 +299,7 @@ class ImHostServiceSetup {
} else { } else {
getMediaChangesChannel.setMessageHandler(nil) 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 { if let api = api {
checkpointSyncChannel.setMessageHandler { _, reply in checkpointSyncChannel.setMessageHandler { _, reply in
do { do {
@ -312,7 +312,7 @@ class ImHostServiceSetup {
} else { } else {
checkpointSyncChannel.setMessageHandler(nil) 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 { if let api = api {
clearSyncCheckpointChannel.setMessageHandler { _, reply in clearSyncCheckpointChannel.setMessageHandler { _, reply in
do { do {
@ -326,8 +326,8 @@ class ImHostServiceSetup {
clearSyncCheckpointChannel.setMessageHandler(nil) clearSyncCheckpointChannel.setMessageHandler(nil)
} }
let getAssetIdsForAlbumChannel = taskQueue == 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.ImHostApi.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, taskQueue: taskQueue)
if let api = api { if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]

View File

@ -1,6 +1,6 @@
import Photos import Photos
class ImHostServiceImpl: ImHostService { class ImHostApiImpl: ImHostApi {
private let mediaManager: MediaManager private let mediaManager: MediaManager

View File

@ -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'; import 'package:immich_mobile/domain/models/local_album.model.dart';
abstract interface class IAlbumMediaRepository { abstract interface class IAlbumMediaRepository {
Future<List<LocalAlbum>> getAll(); Future<List<LocalAlbum>> getAll({bool withModifiedTime = false});
Future<List<LocalAsset>> getAssetsForAlbum( Future<List<LocalAsset>> getAssetsForAlbum(
String albumId, { String albumId, {
DateTimeFilter? updateTimeCond, DateTimeFilter? updateTimeCond,
}); });
Future<LocalAlbum> refresh(
String albumId, {
bool withModifiedTime = true,
bool withAssetCount = true,
});
} }
class DateTimeFilter { class DateTimeFilter {

View File

@ -15,60 +15,59 @@ class DeviceSyncService {
final IAlbumMediaRepository _albumMediaRepository; final IAlbumMediaRepository _albumMediaRepository;
final ILocalAlbumRepository _localAlbumRepository; final ILocalAlbumRepository _localAlbumRepository;
final Platform _platform; final Platform _platform;
final platform.ImHostService _hostService; final platform.ImHostApi _hostApi;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
DeviceSyncService({ DeviceSyncService({
required IAlbumMediaRepository albumMediaRepository, required IAlbumMediaRepository albumMediaRepository,
required ILocalAlbumRepository localAlbumRepository, required ILocalAlbumRepository localAlbumRepository,
required platform.ImHostService hostService, required platform.ImHostApi hostApi,
Platform? platform, Platform? platform,
}) : _albumMediaRepository = albumMediaRepository, }) : _albumMediaRepository = albumMediaRepository,
_localAlbumRepository = localAlbumRepository, _localAlbumRepository = localAlbumRepository,
_platform = platform ?? const LocalPlatform(), _platform = platform ?? const LocalPlatform(),
_hostService = hostService; _hostApi = hostApi;
Future<void> sync() async { Future<void> sync() async {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
try { try {
if (await _hostService.shouldFullSync()) { if (await _hostApi.shouldFullSync()) {
_log.fine("Cannot use partial sync. Performing full sync"); _log.fine("Cannot use partial sync. Performing full sync");
return await fullSync(); return await fullSync();
} }
final delta = await _hostService.getMediaChanges(); final delta = await _hostApi.getMediaChanges();
if (!delta.hasChanges) { if (!delta.hasChanges) {
_log.fine("No media changes detected. Skipping sync"); _log.fine("No media changes detected. Skipping sync");
return; return;
} }
final deviceAlbums = await _albumMediaRepository.getAll(); final deviceAlbums =
await _albumMediaRepository.getAll(withModifiedTime: true);
await _localAlbumRepository.updateAll(deviceAlbums); await _localAlbumRepository.updateAll(deviceAlbums);
await _localAlbumRepository.processDelta(delta); await _localAlbumRepository.processDelta(delta);
if (_platform.isAndroid) { if (_platform.isAndroid) {
final dbAlbums = await _localAlbumRepository.getAll(); final dbAlbums = await _localAlbumRepository.getAll();
for (final album in dbAlbums) { 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 _localAlbumRepository.syncAlbumDeletes(album.id, deviceIds);
} }
} }
await _hostService.checkpointSync(); await _hostApi.checkpointSync();
} catch (e, s) { } catch (e, s) {
_log.severe("Error performing device sync", 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<void> fullSync() async { Future<void> fullSync() async {
try { try {
final Stopwatch stopwatch = Stopwatch()..start(); 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 = final deviceAlbums =
(await _albumMediaRepository.getAll()).sortedBy((a) => a.id); (await _albumMediaRepository.getAll()).sortedBy((a) => a.id);
@ -84,7 +83,7 @@ class DeviceSyncService {
onlySecond: addAlbum, onlySecond: addAlbum,
); );
await _hostService.checkpointSync(); await _hostApi.checkpointSync();
stopwatch.stop(); stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms"); _log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} catch (e, s) { } catch (e, s) {
@ -92,10 +91,9 @@ class DeviceSyncService {
} }
} }
Future<void> addAlbum(LocalAlbum newAlbum) async { Future<void> addAlbum(LocalAlbum album) async {
try { try {
_log.fine("Adding device album ${newAlbum.name}"); _log.fine("Adding device album ${album.name}");
final album = await _albumMediaRepository.refresh(newAlbum.id);
final assets = album.assetCount > 0 final assets = album.assetCount > 0
? await _albumMediaRepository.getAssetsForAlbum(album.id) ? await _albumMediaRepository.getAssetsForAlbum(album.id)
@ -119,15 +117,11 @@ class DeviceSyncService {
} }
// The deviceAlbum is ignored since we are going to refresh it anyways // The deviceAlbum is ignored since we are going to refresh it anyways
FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum _) async { FutureOr<bool> updateAlbum(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try { try {
_log.fine("Syncing device album ${dbAlbum.name}"); _log.fine("Syncing device album ${dbAlbum.name}");
final deviceAlbum = await _albumMediaRepository.refresh(dbAlbum.id); if (_albumsEqual(deviceAlbum, dbAlbum)) {
// Early return if album hasn't changed
if (deviceAlbum.updatedAt.isAtSameMomentAs(dbAlbum.updatedAt) &&
deviceAlbum.assetCount == dbAlbum.assetCount) {
_log.fine( _log.fine(
"Device album ${dbAlbum.name} has not changed. Skipping sync.", "Device album ${dbAlbum.name} has not changed. Skipping sync.",
); );
@ -293,4 +287,10 @@ class DeviceSyncService {
a.height == b.height && a.height == b.height &&
a.durationInSeconds == b.durationInSeconds; a.durationInSeconds == b.durationInSeconds;
} }
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
return a.name == b.name &&
a.assetCount == b.assetCount &&
a.updatedAt.isAtSameMomentAs(b.updatedAt);
}
} }

View File

@ -11,24 +11,20 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
const AlbumMediaRepository({Platform platform = const LocalPlatform()}) const AlbumMediaRepository({Platform platform = const LocalPlatform()})
: _platform = platform; : _platform = platform;
PMFilter _getAlbumFilter({ PMFilter _getAlbumFilter({DateTimeFilter? updateTimeCond}) =>
withAssetTitle = false,
withModifiedTime = false,
DateTimeFilter? updateTimeCond,
}) =>
FilterOptionGroup( FilterOptionGroup(
imageOption: FilterOption( imageOption: const FilterOption(
// needTitle is expected to be slow on iOS but is required to fetch the asset title // needTitle is expected to be slow on iOS but is required to fetch the asset title
needTitle: withAssetTitle, needTitle: true,
sizeConstraint: const SizeConstraint(ignoreSize: true), sizeConstraint: SizeConstraint(ignoreSize: true),
), ),
videoOption: FilterOption( videoOption: const FilterOption(
needTitle: withAssetTitle, needTitle: true,
sizeConstraint: const SizeConstraint(ignoreSize: true), sizeConstraint: SizeConstraint(ignoreSize: true),
durationConstraint: const DurationConstraint(allowNullable: true), durationConstraint: DurationConstraint(allowNullable: true),
), ),
// This is needed to get the modified time of the album // This is needed to get the modified time of the album
containsPathModified: withModifiedTime, containsPathModified: true,
createTimeCond: DateTimeCond.def().copyWith(ignore: true), createTimeCond: DateTimeCond.def().copyWith(ignore: true),
updateTimeCond: updateTimeCond == null updateTimeCond: updateTimeCond == null
? DateTimeCond.def().copyWith(ignore: true) ? DateTimeCond.def().copyWith(ignore: true)
@ -40,10 +36,10 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
); );
@override @override
Future<List<LocalAlbum>> getAll() { Future<List<LocalAlbum>> getAll({bool withModifiedTime = false}) {
return PhotoManager.getAssetPathList( return PhotoManager.getAssetPathList(
hasAll: true, hasAll: true,
filterOption: AdvancedCustomFilter(), filterOption: _getAlbumFilter(),
).then((e) { ).then((e) {
if (_platform.isAndroid) { if (_platform.isAndroid) {
e.removeWhere((a) => a.isAll); e.removeWhere((a) => a.isAll);
@ -59,10 +55,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
}) async { }) async {
final assetPathEntity = await AssetPathEntity.obtainPathFromProperties( final assetPathEntity = await AssetPathEntity.obtainPathFromProperties(
id: albumId, id: albumId,
optionGroup: _getAlbumFilter( optionGroup: _getAlbumFilter(updateTimeCond: updateTimeCond),
withAssetTitle: true,
updateTimeCond: updateTimeCond,
),
); );
final assets = <AssetEntity>[]; final assets = <AssetEntity>[];
int pageNumber = 0, lastPageCount = 0; int pageNumber = 0, lastPageCount = 0;
@ -77,17 +70,6 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
} while (lastPageCount == kFetchLocalAssetsBatchSize); } while (lastPageCount == kFetchLocalAssetsBatchSize);
return Future.wait(assets.map((a) => a.toDto())); return Future.wait(assets.map((a) => a.toDto()));
} }
@override
Future<LocalAlbum> 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 { extension on AssetEntity {

View File

@ -171,7 +171,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
], ],
) )
..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.id)]); ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
return query return query
.map((row) => row.readTable(_db.localAssetEntity).toDto()) .map((row) => row.readTable(_db.localAssetEntity).toDto())
.get(); .get();
@ -193,25 +193,37 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
await _deleteAssets(delta.deletes); await _deleteAssets(delta.deletes);
await _upsertAssets(delta.updates.map((a) => a.toLocalAsset())); 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<Object?> instead of
// List<String>
await _db.batch((batch) async { await _db.batch((batch) async {
for (final asset in delta.updates) { delta.albumAssets
.cast<String, List<Object?>>()
.forEach((assetId, albumIds) {
batch.deleteWhere( batch.deleteWhere(
_db.localAlbumAssetEntity, _db.localAlbumAssetEntity,
(f) => (f) =>
f.albumId.isNotIn(asset.albumIds) & f.assetId.equals(asset.id), f.albumId.isNotIn(albumIds.cast<String?>().nonNulls) &
f.assetId.equals(assetId),
); );
});
});
await _db.batch((batch) async {
delta.albumAssets
.cast<String, List<Object?>>()
.forEach((assetId, albumIds) {
batch.insertAll( batch.insertAll(
_db.localAlbumAssetEntity, _db.localAlbumAssetEntity,
asset.albumIds.map( albumIds.cast<String?>().nonNulls.map(
(albumId) => LocalAlbumAssetEntityCompanion.insert( (albumId) => LocalAlbumAssetEntityCompanion.insert(
assetId: asset.id, assetId: assetId,
albumId: albumId, albumId: albumId,
), ),
), ),
onConflict: DoNothing(), onConflict: DoNothing(),
); );
} });
}); });
}); });
} }

View File

@ -20,7 +20,6 @@ class PlatformAsset {
final int? createdAt; final int? createdAt;
final int? updatedAt; final int? updatedAt;
final int durationInSeconds; final int durationInSeconds;
final List<String> albumIds;
const PlatformAsset({ const PlatformAsset({
required this.id, required this.id,
@ -29,23 +28,26 @@ class PlatformAsset {
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,
this.durationInSeconds = 0, this.durationInSeconds = 0,
this.albumIds = const [],
}); });
} }
class SyncDelta { class SyncDelta {
SyncDelta({ final bool hasChanges;
final List<PlatformAsset> updates;
final List<String> deletes;
// Asset -> Album mapping
final Map<String, List<String>> albumAssets;
const SyncDelta({
this.hasChanges = false, this.hasChanges = false,
this.updates = const [], this.updates = const [],
this.deletes = const [], this.deletes = const [],
this.albumAssets = const {},
}); });
bool hasChanges;
List<PlatformAsset> updates;
List<String> deletes;
} }
@HostApi() @HostApi()
abstract class ImHostService { abstract class ImHostApi {
bool shouldFullSync(); bool shouldFullSync();
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)

View File

@ -14,22 +14,21 @@ PlatformException _createConnectionError(String channelName) {
message: 'Unable to establish connection on channel: "$channelName".', message: 'Unable to establish connection on channel: "$channelName".',
); );
} }
bool _deepEquals(Object? a, Object? b) { bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) { if (a is List && b is List) {
return a.length == b.length && return a.length == b.length &&
a.indexed 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) { if (a is Map && b is Map) {
return a.length == b.length && return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
a.entries.every((MapEntry<Object?, Object?> entry) => (b as Map<Object?, Object?>).containsKey(entry.key) &&
(b as Map<Object?, Object?>).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]));
_deepEquals(entry.value, b[entry.key]));
} }
return a == b; return a == b;
} }
class PlatformAsset { class PlatformAsset {
PlatformAsset({ PlatformAsset({
required this.id, required this.id,
@ -38,7 +37,6 @@ class PlatformAsset {
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,
required this.durationInSeconds, required this.durationInSeconds,
required this.albumIds,
}); });
String id; String id;
@ -53,8 +51,6 @@ class PlatformAsset {
int durationInSeconds; int durationInSeconds;
List<String> albumIds;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[
id, id,
@ -63,13 +59,11 @@ class PlatformAsset {
createdAt, createdAt,
updatedAt, updatedAt,
durationInSeconds, durationInSeconds,
albumIds,
]; ];
} }
Object encode() { Object encode() {
return _toList(); return _toList(); }
}
static PlatformAsset decode(Object result) { static PlatformAsset decode(Object result) {
result as List<Object?>; result as List<Object?>;
@ -80,7 +74,6 @@ class PlatformAsset {
createdAt: result[3] as int?, createdAt: result[3] as int?,
updatedAt: result[4] as int?, updatedAt: result[4] as int?,
durationInSeconds: result[5]! as int, durationInSeconds: result[5]! as int,
albumIds: (result[6] as List<Object?>?)!.cast<String>(),
); );
} }
@ -98,14 +91,16 @@ class PlatformAsset {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList()); int get hashCode => Object.hashAll(_toList())
;
} }
class SyncDelta { class SyncDelta {
SyncDelta({ SyncDelta({
this.hasChanges = false, required this.hasChanges,
this.updates = const [], required this.updates,
this.deletes = const [], required this.deletes,
required this.albumAssets,
}); });
bool hasChanges; bool hasChanges;
@ -114,17 +109,19 @@ class SyncDelta {
List<String> deletes; List<String> deletes;
Map<String, List<String>> albumAssets;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[
hasChanges, hasChanges,
updates, updates,
deletes, deletes,
albumAssets,
]; ];
} }
Object encode() { Object encode() {
return _toList(); return _toList(); }
}
static SyncDelta decode(Object result) { static SyncDelta decode(Object result) {
result as List<Object?>; result as List<Object?>;
@ -132,6 +129,7 @@ class SyncDelta {
hasChanges: result[0]! as bool, hasChanges: result[0]! as bool,
updates: (result[1] as List<Object?>?)!.cast<PlatformAsset>(), updates: (result[1] as List<Object?>?)!.cast<PlatformAsset>(),
deletes: (result[2] as List<Object?>?)!.cast<String>(), deletes: (result[2] as List<Object?>?)!.cast<String>(),
albumAssets: (result[3] as Map<Object?, Object?>?)!.cast<String, List<String>>(),
); );
} }
@ -149,9 +147,11 @@ class SyncDelta {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // 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 { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@ -159,10 +159,10 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is PlatformAsset) { } else if (value is PlatformAsset) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is SyncDelta) { } else if (value is SyncDelta) {
buffer.putUint8(130); buffer.putUint8(130);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else { } else {
@ -173,9 +173,9 @@ class _PigeonCodec extends StandardMessageCodec {
@override @override
Object? readValueOfType(int type, ReadBuffer buffer) { Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) { switch (type) {
case 129: case 129:
return PlatformAsset.decode(readValue(buffer)!); return PlatformAsset.decode(readValue(buffer)!);
case 130: case 130:
return SyncDelta.decode(readValue(buffer)!); return SyncDelta.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
@ -183,15 +183,13 @@ class _PigeonCodec extends StandardMessageCodec {
} }
} }
class ImHostService { class ImHostApi {
/// Constructor for [ImHostService]. The [binaryMessenger] named argument is /// Constructor for [ImHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
ImHostService( ImHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
{BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger; final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@ -199,10 +197,8 @@ class ImHostService {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<bool> shouldFullSync() async { Future<bool> shouldFullSync() async {
final String pigeonVar_channelName = final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.shouldFullSync$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync$pigeonVar_messageChannelSuffix'; final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
@ -229,10 +225,8 @@ class ImHostService {
} }
Future<SyncDelta> getMediaChanges() async { Future<SyncDelta> getMediaChanges() async {
final String pigeonVar_channelName = final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges$pigeonVar_messageChannelSuffix'; final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
@ -259,10 +253,8 @@ class ImHostService {
} }
Future<void> checkpointSync() async { Future<void> checkpointSync() async {
final String pigeonVar_channelName = final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.checkpointSync$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync$pigeonVar_messageChannelSuffix'; final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
@ -284,10 +276,8 @@ class ImHostService {
} }
Future<void> clearSyncCheckpoint() async { Future<void> clearSyncCheckpoint() async {
final String pigeonVar_channelName = final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.ImHostService.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
@ -309,16 +299,13 @@ class ImHostService {
} }
Future<List<String>> getAssetIdsForAlbum(String albumId) async { Future<List<String>> getAssetIdsForAlbum(String albumId) async {
final String pigeonVar_channelName = final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
'dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[albumId]);
pigeonVar_channel.send(<Object?>[albumId]);
final List<Object?>? pigeonVar_replyList = final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?; await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {

View File

@ -30,7 +30,7 @@ final _features = [
_Feature( _Feature(
name: 'Clear Delta Checkpoint', name: 'Clear Delta Checkpoint',
icon: Icons.delete_rounded, icon: Icons.delete_rounded,
onTap: (_, ref) => ref.read(hostServiceProvider).clearSyncCheckpoint(), onTap: (_, ref) => ref.read(hostApiProvider).clearSyncCheckpoint(),
), ),
_Feature( _Feature(
name: 'Clear Local Data', name: 'Clear Local Data',

View File

@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/messages.g.dart'; import 'package:immich_mobile/platform/messages.g.dart';
final hostServiceProvider = Provider<ImHostService>((_) => ImHostService()); final hostApiProvider = Provider<ImHostApi>((_) => ImHostApi());

View File

@ -13,7 +13,7 @@ final deviceSyncServiceProvider = Provider(
(ref) => DeviceSyncService( (ref) => DeviceSyncService(
albumMediaRepository: ref.watch(albumMediaRepositoryProvider), albumMediaRepository: ref.watch(albumMediaRepositoryProvider),
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
hostService: ref.watch(hostServiceProvider), hostApi: ref.watch(hostApiProvider),
), ),
); );

View File

@ -11,6 +11,6 @@ class MockUserService extends Mock implements UserService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockHostService extends Mock implements ImHostService {} class MockHostApi extends Mock implements ImHostApi {}
class MockPlatform extends Mock implements Platform {} class MockPlatform extends Mock implements Platform {}

View File

@ -15,8 +15,8 @@ import '../service.mock.dart';
void main() { void main() {
late IAlbumMediaRepository mockAlbumMediaRepo; late IAlbumMediaRepository mockAlbumMediaRepo;
late ILocalAlbumRepository mockLocalAlbumRepo; late ILocalAlbumRepository mockLocalAlbumRepo;
late ImHostService mockHostService; late ImHostApi mockHostApi;
late MockPlatform mockPlatformInstance; late MockPlatform mockPlatform;
late DeviceSyncService sut; late DeviceSyncService sut;
Future<T> mockTransaction<T>(Future<T> Function() action) => action(); Future<T> mockTransaction<T>(Future<T> Function() action) => action();
@ -24,20 +24,20 @@ void main() {
setUp(() { setUp(() {
mockAlbumMediaRepo = MockAlbumMediaRepository(); mockAlbumMediaRepo = MockAlbumMediaRepository();
mockLocalAlbumRepo = MockLocalAlbumRepository(); mockLocalAlbumRepo = MockLocalAlbumRepository();
mockHostService = MockHostService(); mockHostApi = MockHostApi();
mockPlatformInstance = MockPlatform(); mockPlatform = MockPlatform();
sut = DeviceSyncService( sut = DeviceSyncService(
albumMediaRepository: mockAlbumMediaRepo, albumMediaRepository: mockAlbumMediaRepo,
localAlbumRepository: mockLocalAlbumRepo, localAlbumRepository: mockLocalAlbumRepo,
hostService: mockHostService, hostApi: mockHostApi,
platform: mockPlatformInstance, platform: mockPlatform,
); );
registerFallbackValue(LocalAlbumStub.album1); registerFallbackValue(LocalAlbumStub.album1);
registerFallbackValue(LocalAssetStub.image1); registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue( registerFallbackValue(
SyncDelta(hasChanges: true, updates: [], deletes: []), SyncDelta(hasChanges: true, updates: [], deletes: [], albumAssets: {}),
); );
when(() => mockAlbumMediaRepo.getAll()).thenAnswer((_) async => []); when(() => mockAlbumMediaRepo.getAll()).thenAnswer((_) async => []);
@ -82,13 +82,18 @@ void main() {
when(() => mockHostService.shouldFullSync()).thenAnswer((_) async => true); when(() => mockHostService.shouldFullSync()).thenAnswer((_) async => true);
when(() => mockHostService.getMediaChanges()).thenAnswer( when(() => mockHostService.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: false, updates: [], deletes: []), (_) async => SyncDelta(
hasChanges: false,
updates: [],
deletes: [],
albumAssets: {},
),
); );
when(() => mockHostService.getAssetIdsForAlbum(any())) when(() => mockHostService.getAssetIdsForAlbum(any()))
.thenAnswer((_) async => []); .thenAnswer((_) async => []);
when(() => mockHostService.checkpointSync()).thenAnswer((_) async => {}); when(() => mockHostService.checkpointSync()).thenAnswer((_) async => {});
when(() => mockPlatformInstance.isAndroid).thenReturn(false); when(() => mockPlatform.isAndroid).thenReturn(false);
}); });
group('sync', () { group('sync', () {
@ -119,7 +124,12 @@ void main() {
when(() => mockHostService.shouldFullSync()) when(() => mockHostService.shouldFullSync())
.thenAnswer((_) async => false); .thenAnswer((_) async => false);
when(() => mockHostService.getMediaChanges()).thenAnswer( when(() => mockHostService.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: false, updates: [], deletes: []), (_) async => SyncDelta(
hasChanges: false,
updates: [],
deletes: [],
albumAssets: {},
),
); );
await sut.sync(); await sut.sync();
@ -140,6 +150,9 @@ void main() {
hasChanges: true, hasChanges: true,
updates: [PlatformAssetStub.image1], updates: [PlatformAssetStub.image1],
deletes: ["deleted"], deletes: ["deleted"],
albumAssets: {
"albumId": ["asset1", "asset2"],
},
); );
final deviceAlbums = [LocalAlbumStub.album1]; final deviceAlbums = [LocalAlbumStub.album1];
@ -149,7 +162,7 @@ void main() {
.thenAnswer((_) async => delta); .thenAnswer((_) async => delta);
when(() => mockAlbumMediaRepo.getAll()) when(() => mockAlbumMediaRepo.getAll())
.thenAnswer((_) async => deviceAlbums); .thenAnswer((_) async => deviceAlbums);
when(() => mockPlatformInstance.isAndroid).thenReturn(false); when(() => mockPlatform.isAndroid).thenReturn(false);
await sut.sync(); await sut.sync();
@ -172,6 +185,9 @@ void main() {
hasChanges: true, hasChanges: true,
updates: [PlatformAssetStub.image1], updates: [PlatformAssetStub.image1],
deletes: ["deleted"], deletes: ["deleted"],
albumAssets: {
"dbAlbumId": ["asset1", "asset2"],
},
); );
final deviceAlbums = [LocalAlbumStub.album1]; final deviceAlbums = [LocalAlbumStub.album1];
final dbAlbums = [LocalAlbumStub.album2.copyWith(id: "dbAlbumId")]; final dbAlbums = [LocalAlbumStub.album2.copyWith(id: "dbAlbumId")];
@ -185,7 +201,7 @@ void main() {
.thenAnswer((_) async => deviceAlbums); .thenAnswer((_) async => deviceAlbums);
when(() => mockLocalAlbumRepo.getAll()) when(() => mockLocalAlbumRepo.getAll())
.thenAnswer((_) async => dbAlbums); .thenAnswer((_) async => dbAlbums);
when(() => mockPlatformInstance.isAndroid).thenReturn(true); when(() => mockPlatform.isAndroid).thenReturn(true);
when(() => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id)) when(() => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id))
.thenAnswer((_) async => assetIdsForDbAlbum); .thenAnswer((_) async => assetIdsForDbAlbum);
@ -197,7 +213,7 @@ void main() {
() => mockAlbumMediaRepo.getAll(), () => mockAlbumMediaRepo.getAll(),
() => mockLocalAlbumRepo.updateAll(deviceAlbums), () => mockLocalAlbumRepo.updateAll(deviceAlbums),
() => mockLocalAlbumRepo.processDelta(delta), () => mockLocalAlbumRepo.processDelta(delta),
() => mockPlatformInstance.isAndroid, () => mockPlatform.isAndroid,
() => mockLocalAlbumRepo.getAll(), () => mockLocalAlbumRepo.getAll(),
() => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id), () => mockHostService.getAssetIdsForAlbum(dbAlbums.first.id),
() => mockLocalAlbumRepo.syncAlbumDeletes( () => mockLocalAlbumRepo.syncAlbumDeletes(

View File

@ -8,7 +8,6 @@ abstract final class PlatformAssetStub {
createdAt: DateTime(2024, 1, 1).millisecondsSinceEpoch, createdAt: DateTime(2024, 1, 1).millisecondsSinceEpoch,
updatedAt: DateTime(2024, 1, 1).millisecondsSinceEpoch, updatedAt: DateTime(2024, 1, 1).millisecondsSinceEpoch,
durationInSeconds: 0, durationInSeconds: 0,
albumIds: ["album1"],
); );
static PlatformAsset get video1 => PlatformAsset( static PlatformAsset get video1 => PlatformAsset(
@ -18,6 +17,5 @@ abstract final class PlatformAssetStub {
createdAt: DateTime(2024, 1, 2).millisecondsSinceEpoch, createdAt: DateTime(2024, 1, 2).millisecondsSinceEpoch,
updatedAt: DateTime(2024, 1, 2).millisecondsSinceEpoch, updatedAt: DateTime(2024, 1, 2).millisecondsSinceEpoch,
durationInSeconds: 120, durationInSeconds: 120,
albumIds: ["album1"],
); );
} }