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.engine.FlutterEngine
import app.alextran.immich.platform.MessagesImpl
import app.alextran.immich.platform.ImHostApiImpl
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
ImHostService.setUp(flutterEngine.dartExecutor.binaryMessenger, MessagesImpl(this))
ImHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ImHostApiImpl(this))
}
}

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ import UIKit
// Register pigeon handler
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
ImHostServiceSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ImHostServiceImpl())
ImHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ImHostApiImpl())
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
if !registry.hasPlugin("org.cocoapods.path-provider-foundation") {

View File

@ -97,7 +97,7 @@ class MediaManager {
let currentToken = PHPhotoLibrary.shared().currentChangeToken
if storedToken == currentToken {
return SyncDelta(hasChanges: false, updates: [], deletes: [])
return SyncDelta(hasChanges: false, updates: [], deletes: [], albumAssets: [:])
}
do {
@ -110,6 +110,7 @@ class MediaManager {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue }
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: nil)
@ -117,7 +118,7 @@ class MediaManager {
let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset(id: asset.localIdentifier, name: "", type: 0, createdAt: nil, updatedAt: nil, durationInSeconds: 0, albumIds: [])
let predicate = PlatformAsset(id: asset.localIdentifier, name: "", type: 0, createdAt: nil, updatedAt: nil, durationInSeconds: 0)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue
}
@ -136,37 +137,33 @@ class MediaManager {
createdAt: createdAt.map { Int64($0) },
updatedAt: updatedAt.map { Int64($0) },
durationInSeconds: durationInSeconds,
albumIds: self._getAlbumIds(forAsset: asset)
))
updatedAssets.insert(domainAsset)
}
deletedAssets.formUnion(details.deletedLocalIdentifiers)
}
return SyncDelta(hasChanges: true, updates: Array(updatedAssets.map { $0.asset }), deletes: Array(deletedAssets))
let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), albumAssets: buildAlbumAssetsMap(assets: updates))
}
}
@available(iOS 16, *)
func _getAlbumIds(forAsset: PHAsset) -> [String] {
var albumIds: [String] = []
private func buildAlbumAssetsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
var albumAssets: [String: [String]] = [:]
let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in
var options = PHFetchOptions()
options.fetchLimit = 1
options.predicate = NSPredicate(format: "localIdentifier == %@", forAsset.localIdentifier)
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "localIdentifier IN %@", assets.map(\.id))
let result = PHAsset.fetchAssets(in: album, options: options)
if(result.count == 1) {
albumIds.append(album.localIdentifier)
for i in 0..<result.count {
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 updatedAt: Int64? = nil
var durationInSeconds: Int64
var albumIds: [String]
// swift-format-ignore: AlwaysUseLowerCamelCase
@ -147,7 +146,6 @@ struct PlatformAsset: Hashable {
let createdAt: Int64? = nilOrValue(pigeonVar_list[3])
let updatedAt: Int64? = nilOrValue(pigeonVar_list[4])
let durationInSeconds = pigeonVar_list[5] as! Int64
let albumIds = pigeonVar_list[6] as! [String]
return PlatformAsset(
id: id,
@ -155,8 +153,7 @@ struct PlatformAsset: Hashable {
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
albumIds: albumIds
durationInSeconds: durationInSeconds
)
}
func toList() -> [Any?] {
@ -167,7 +164,6 @@ struct PlatformAsset: Hashable {
createdAt,
updatedAt,
durationInSeconds,
albumIds,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
@ -182,6 +178,7 @@ struct SyncDelta: Hashable {
var hasChanges: Bool
var updates: [PlatformAsset]
var deletes: [String]
var albumAssets: [String: [String]]
// swift-format-ignore: AlwaysUseLowerCamelCase
@ -189,11 +186,13 @@ struct SyncDelta: Hashable {
let hasChanges = pigeonVar_list[0] as! Bool
let updates = pigeonVar_list[1] as! [PlatformAsset]
let deletes = pigeonVar_list[2] as! [String]
let albumAssets = pigeonVar_list[3] as! [String: [String]]
return SyncDelta(
hasChanges: hasChanges,
updates: updates,
deletes: deletes
deletes: deletes,
albumAssets: albumAssets
)
}
func toList() -> [Any?] {
@ -201,6 +200,7 @@ struct SyncDelta: Hashable {
hasChanges,
updates,
deletes,
albumAssets,
]
}
static func == (lhs: SyncDelta, rhs: SyncDelta) -> Bool {
@ -252,7 +252,7 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ImHostService {
protocol ImHostApi {
func shouldFullSync() throws -> Bool
func getMediaChanges() throws -> SyncDelta
func checkpointSync() throws
@ -261,17 +261,17 @@ protocol ImHostService {
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class ImHostServiceSetup {
class ImHostApiSetup {
static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared }
/// Sets up an instance of `ImHostService` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImHostService?, messageChannelSuffix: String = "") {
/// Sets up an instance of `ImHostApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImHostApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
#if os(iOS)
let taskQueue = binaryMessenger.makeBackgroundTaskQueue?()
#else
let taskQueue: FlutterTaskQueue? = nil
#endif
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let shouldFullSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.shouldFullSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
shouldFullSyncChannel.setMessageHandler { _, reply in
do {
@ -285,8 +285,8 @@ class ImHostServiceSetup {
shouldFullSyncChannel.setMessageHandler(nil)
}
let getMediaChangesChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getMediaChangesChannel.setMessageHandler { _, reply in
do {
@ -299,7 +299,7 @@ class ImHostServiceSetup {
} else {
getMediaChangesChannel.setMessageHandler(nil)
}
let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let checkpointSyncChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.checkpointSync\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
checkpointSyncChannel.setMessageHandler { _, reply in
do {
@ -312,7 +312,7 @@ class ImHostServiceSetup {
} else {
checkpointSyncChannel.setMessageHandler(nil)
}
let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
let clearSyncCheckpointChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.clearSyncCheckpoint\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
clearSyncCheckpointChannel.setMessageHandler { _, reply in
do {
@ -326,8 +326,8 @@ class ImHostServiceSetup {
clearSyncCheckpointChannel.setMessageHandler(nil)
}
let getAssetIdsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostService.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ImHostApi.getAssetIdsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]

View File

@ -1,6 +1,6 @@
import Photos
class ImHostServiceImpl: ImHostService {
class ImHostApiImpl: ImHostApi {
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';
abstract interface class IAlbumMediaRepository {
Future<List<LocalAlbum>> getAll();
Future<List<LocalAlbum>> getAll({bool withModifiedTime = false});
Future<List<LocalAsset>> getAssetsForAlbum(
String albumId, {
DateTimeFilter? updateTimeCond,
});
Future<LocalAlbum> refresh(
String albumId, {
bool withModifiedTime = true,
bool withAssetCount = true,
});
}
class DateTimeFilter {

View File

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

View File

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

View File

@ -171,7 +171,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
],
)
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
..orderBy([OrderingTerm.desc(_db.localAssetEntity.id)]);
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
@ -193,25 +193,37 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
await _deleteAssets(delta.deletes);
await _upsertAssets(delta.updates.map((a) => a.toLocalAsset()));
// The ugly casting below is required for now because the generated code
// casts the returned values from the platform during decoding them
// and iterating over them causes the type to be List<Object?> instead of
// List<String>
await _db.batch((batch) async {
for (final asset in delta.updates) {
delta.albumAssets
.cast<String, List<Object?>>()
.forEach((assetId, albumIds) {
batch.deleteWhere(
_db.localAlbumAssetEntity,
(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(
_db.localAlbumAssetEntity,
asset.albumIds.map(
(albumId) => LocalAlbumAssetEntityCompanion.insert(
assetId: asset.id,
albumId: albumId,
),
),
albumIds.cast<String?>().nonNulls.map(
(albumId) => LocalAlbumAssetEntityCompanion.insert(
assetId: assetId,
albumId: albumId,
),
),
onConflict: DoNothing(),
);
}
});
});
});
}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.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(
albumMediaRepository: ref.watch(albumMediaRepositoryProvider),
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 MockHostService extends Mock implements ImHostService {}
class MockHostApi extends Mock implements ImHostApi {}
class MockPlatform extends Mock implements Platform {}

View File

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

View File

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