remove photo_manager dep for sync

This commit is contained in:
shenlong-tanwen 2025-05-24 00:58:55 +05:30
parent a41a156ce4
commit 5ea136cd32
15 changed files with 381 additions and 273 deletions

View File

@ -245,6 +245,8 @@ interface NativeSyncApi {
fun clearSyncCheckpoint() fun clearSyncCheckpoint()
fun getAssetIdsForAlbum(albumId: String): List<String> fun getAssetIdsForAlbum(albumId: String): List<String>
fun getAlbums(): List<ImAlbum> fun getAlbums(): List<ImAlbum>
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<ImAsset>
companion object { companion object {
/** The codec used by NativeSyncApi. */ /** The codec used by NativeSyncApi. */
@ -350,6 +352,42 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val timestampArg = args[1] as Long
val wrapped: List<Any?> = try {
listOf(api.getAssetsCountSince(albumIdArg, timestampArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val albumIdArg = args[0] as String
val updatedTimeCondArg = args[1] as Long?
val wrapped: List<Any?> = try {
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }

View File

@ -18,10 +18,6 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
// No-op for Android 10 and below // No-op for Android 10 and below
} }
override fun getAssetIdsForAlbum(albumId: String): List<String> {
throw IllegalStateException("Method not supported on this Android version.")
}
override fun getMediaChanges(): SyncDelta { override fun getMediaChanges(): SyncDelta {
throw IllegalStateException("Method not supported on this Android version.") throw IllegalStateException("Method not supported on this Android version.")
} }

View File

@ -47,12 +47,6 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
} }
} }
override fun getAssetIdsForAlbum(albumId: String): List<String> = getAssets(
MediaStore.VOLUME_EXTERNAL,
"${MediaStore.Files.FileColumns.BUCKET_ID} = ? AND $MEDIA_SELECTION",
arrayOf(albumId, *MEDIA_SELECTION_ARGS)
).mapNotNull { (it as? AssetResult.ValidAsset)?.asset?.id }.toList()
override fun getMediaChanges(): SyncDelta { override fun getMediaChanges(): SyncDelta {
val genMap = getSavedGenerationMap() val genMap = getSavedGenerationMap()
val currentVolumes = MediaStore.getExternalVolumeNames(ctx) val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
@ -78,7 +72,7 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
storedGen.toString() storedGen.toString()
) )
getAssets(volume, selection, selectionArgs).forEach { getAssets(getCursor(volume, selection, selectionArgs)).forEach {
when (it) { when (it) {
is AssetResult.ValidAsset -> { is AssetResult.ValidAsset -> {
changed.add(it.asset) changed.add(it.asset)

View File

@ -2,6 +2,7 @@ package app.alextran.immich.sync
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.database.Cursor
import android.provider.MediaStore import android.provider.MediaStore
import java.io.File import java.io.File
@ -10,6 +11,7 @@ sealed class AssetResult {
data class InvalidAsset(val assetId: String) : AssetResult() data class InvalidAsset(val assetId: String) : AssetResult()
} }
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) { open class NativeSyncApiImplBase(context: Context) {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
@ -20,14 +22,8 @@ open class NativeSyncApiImplBase(context: Context) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(), MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString() MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
) )
} const val BUCKET_SELECTION = "(${MediaStore.Files.FileColumns.BUCKET_ID} = ?)"
val ASSET_PROJECTION = arrayOf(
protected fun getAssets(
volume: String,
selection: String,
selectionArgs: Array<String>,
): Sequence<AssetResult> {
val projection = arrayOf(
MediaStore.MediaColumns._ID, MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.DISPLAY_NAME,
@ -38,61 +34,63 @@ open class NativeSyncApiImplBase(context: Context) {
MediaStore.MediaColumns.BUCKET_ID, MediaStore.MediaColumns.BUCKET_ID,
MediaStore.MediaColumns.DURATION MediaStore.MediaColumns.DURATION
) )
}
return sequence { protected fun getCursor(
ctx.contentResolver.query( volume: String,
selection: String,
selectionArgs: Array<String>,
projection: Array<String> = ASSET_PROJECTION,
sortOrder: String? = null
): Cursor? = ctx.contentResolver.query(
MediaStore.Files.getContentUri(volume), MediaStore.Files.getContentUri(volume),
projection, projection,
selection, selection,
selectionArgs, selectionArgs,
null sortOrder,
)?.use { cursor -> )
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
val dateTakenColumn =
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN)
val dateAddedColumn =
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn =
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val mediaTypeColumn =
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
while (cursor.moveToNext()) { protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
val id = cursor.getLong(idColumn).toString() return sequence {
val path = cursor.getString(dataColumn) cursor?.use { c ->
val idColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val dataColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
val nameColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
val dateTakenColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN)
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
while (c.moveToNext()) {
val id = c.getLong(idColumn).toString()
val path = c.getString(dataColumn)
if (path.isNullOrBlank() || !File(path).exists()) { if (path.isNullOrBlank() || !File(path).exists()) {
yield(AssetResult.InvalidAsset(id)) yield(AssetResult.InvalidAsset(id))
continue continue
} }
val mediaType = cursor.getInt(mediaTypeColumn) val mediaType = c.getInt(mediaTypeColumn)
val name = cursor.getString(nameColumn) val name = c.getString(nameColumn)
// Date taken is milliseconds since epoch, Date added is seconds since epoch // Date taken is milliseconds since epoch, Date added is seconds since epoch
val createdAt = (cursor.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
?: cursor.getLong(dateAddedColumn) ?: c.getLong(dateAddedColumn)
// Date modified is seconds since epoch // Date modified is seconds since epoch
val modifiedAt = cursor.getLong(dateModifiedColumn) val modifiedAt = c.getLong(dateModifiedColumn)
// Duration is milliseconds // Duration is milliseconds
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0 val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
else cursor.getLong(durationColumn) / 1000 else c.getLong(durationColumn) / 1000
val bucketId = cursor.getString(bucketIdColumn) val bucketId = c.getString(bucketIdColumn)
yield( val asset = ImAsset(id, name, mediaType.toLong(), createdAt, modifiedAt, duration)
AssetResult.ValidAsset( yield(AssetResult.ValidAsset(asset, bucketId))
ImAsset(id, name, mediaType.toLong(), createdAt, modifiedAt, duration),
bucketId
)
)
} }
} }
} }
} }
@SuppressLint("InlinedApi")
fun getAlbums(): List<ImAlbum> { fun getAlbums(): List<ImAlbum> {
val albums = mutableListOf<ImAlbum>() val albums = mutableListOf<ImAlbum>()
val albumsCount = mutableMapOf<String, Int>() val albumsCount = mutableMapOf<String, Int>()
@ -105,11 +103,11 @@ open class NativeSyncApiImplBase(context: Context) {
val selection = val selection =
"(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION" "(${MediaStore.Files.FileColumns.BUCKET_ID} IS NOT NULL) AND $MEDIA_SELECTION"
ctx.contentResolver.query( getCursor(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), MediaStore.VOLUME_EXTERNAL,
projection,
selection, selection,
MEDIA_SELECTION_ARGS, MEDIA_SELECTION_ARGS,
projection,
"${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC"
)?.use { cursor -> )?.use { cursor ->
val bucketIdColumn = val bucketIdColumn =
@ -121,6 +119,7 @@ open class NativeSyncApiImplBase(context: Context) {
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getString(bucketIdColumn) val id = cursor.getString(bucketIdColumn)
val count = albumsCount.getOrDefault(id, 0) val count = albumsCount.getOrDefault(id, 0)
if (count != 0) { if (count != 0) {
albumsCount[id] = count + 1 albumsCount[id] = count + 1
@ -134,9 +133,45 @@ open class NativeSyncApiImplBase(context: Context) {
} }
} }
return albums.map { album -> return albums.map { it.copy(assetCount = albumsCount[it.id]?.toLong() ?: 0) }
val count = albumsCount[album.id] ?: 0 .sortedBy { it.id }
album.copy(assetCount = count.toLong()) }
}.sortedBy { it.id }
fun getAssetIdsForAlbum(albumId: String): List<String> {
val projection = arrayOf(MediaStore.MediaColumns._ID)
return getCursor(
MediaStore.VOLUME_EXTERNAL,
"$BUCKET_SELECTION AND $MEDIA_SELECTION",
arrayOf(albumId, *MEDIA_SELECTION_ARGS),
projection
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
generateSequence {
if (cursor.moveToNext()) cursor.getLong(idColumn).toString() else null
}.toList()
} ?: emptyList()
}
fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
getCursor(
MediaStore.VOLUME_EXTERNAL,
"$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION",
arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS),
)?.use { cursor -> cursor.count.toLong() } ?: 0L
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<ImAsset> {
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
if (updatedTimeCond != null) {
selection += " AND (${MediaStore.Files.FileColumns.DATE_MODIFIED} > ? OR ${MediaStore.Files.FileColumns.DATE_ADDED} > ?)"
selectionArgs.addAll(listOf(updatedTimeCond.toString(), updatedTimeCond.toString()))
}
return getAssets(getCursor(MediaStore.VOLUME_EXTERNAL, selection, selectionArgs.toTypedArray()))
.mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset }
.toList()
} }
} }

View File

@ -305,6 +305,8 @@ protocol NativeSyncApi {
func clearSyncCheckpoint() throws func clearSyncCheckpoint() throws
func getAssetIdsForAlbum(albumId: String) throws -> [String] func getAssetIdsForAlbum(albumId: String) throws -> [String]
func getAlbums() throws -> [ImAlbum] func getAlbums() throws -> [ImAlbum]
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [ImAsset]
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@ -404,5 +406,41 @@ class NativeSyncApiSetup {
} else { } else {
getAlbumsChannel.setMessageHandler(nil) getAlbumsChannel.setMessageHandler(nil)
} }
let getAssetsCountSinceChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetsCountSinceChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let timestampArg = args[1] as! Int64
do {
let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAssetsCountSinceChannel.setMessageHandler(nil)
}
let getAssetsForAlbumChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getAssetsForAlbumChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let albumIdArg = args[0] as! String
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
do {
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getAssetsForAlbumChannel.setMessageHandler(nil)
}
} }
} }

View File

@ -199,4 +199,36 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
return ids return ids
} }
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return 0
}
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
let assets = PHAsset.fetchAssets(in: album, options: options)
return Int64(assets.count)
}
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [ImAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else {
return []
}
let options = PHFetchOptions()
if(updatedTimeCond != nil) {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
}
let result = PHAsset.fetchAssets(in: album, options: options)
var assets: [ImAsset] = []
result.enumerateObjects { (asset, _, _) in
assets.append(asset.toImAsset())
}
return assets
}
} }

View File

@ -1,15 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
abstract interface class IAlbumMediaRepository {
Future<List<LocalAsset>> getAssetsForAlbum(
String albumId, {
DateTimeFilter? updateTimeCond,
});
}
class DateTimeFilter {
final DateTime min;
final DateTime max;
const DateTimeFilter({required this.min, required this.max});
}

View File

@ -1,7 +1,6 @@
import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/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';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
abstract interface class ILocalAlbumRepository implements IDatabaseRepository { abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy}); Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy});
@ -20,7 +19,11 @@ abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
Future<void> delete(String albumId); Future<void> delete(String albumId);
Future<void> processDelta(SyncDelta delta); Future<void> processDelta({
required List<LocalAsset> updates,
required List<String> deletes,
required Map<String, List<String>> assetAlbums,
});
Future<void> syncAlbumDeletes( Future<void> syncAlbumDeletes(
String albumId, String albumId,

View File

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/domain/models/local_album.model.dart';
@ -15,7 +14,6 @@ import 'package:logging/logging.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
class DeviceSyncService { class DeviceSyncService {
final IAlbumMediaRepository _albumMediaRepository;
final ILocalAlbumRepository _localAlbumRepository; final ILocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final Platform _platform; final Platform _platform;
@ -23,13 +21,11 @@ class DeviceSyncService {
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
DeviceSyncService({ DeviceSyncService({
required IAlbumMediaRepository albumMediaRepository,
required ILocalAlbumRepository localAlbumRepository, required ILocalAlbumRepository localAlbumRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
required StoreService storeService, required StoreService storeService,
Platform? platform, Platform? platform,
}) : _albumMediaRepository = albumMediaRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAlbumRepository = localAlbumRepository,
_nativeSyncApi = nativeSyncApi, _nativeSyncApi = nativeSyncApi,
_storeService = storeService, _storeService = storeService,
_platform = platform ?? const LocalPlatform(); _platform = platform ?? const LocalPlatform();
@ -58,7 +54,11 @@ class DeviceSyncService {
final deviceAlbums = await _nativeSyncApi.getAlbums(); final deviceAlbums = await _nativeSyncApi.getAlbums();
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
await _localAlbumRepository.processDelta(delta); await _localAlbumRepository.processDelta(
updates: delta.updates.toLocalAssets(),
deletes: delta.deletes,
assetAlbums: delta.assetAlbums,
);
final dbAlbums = await _localAlbumRepository.getAll(); final dbAlbums = await _localAlbumRepository.getAll();
// On Android, we need to sync all albums since it is not possible to // On Android, we need to sync all albums since it is not possible to
@ -135,10 +135,13 @@ class DeviceSyncService {
_log.fine("Adding device album ${album.name}"); _log.fine("Adding device album ${album.name}");
final assets = album.assetCount > 0 final assets = album.assetCount > 0
? await _albumMediaRepository.getAssetsForAlbum(album.id) ? await _nativeSyncApi.getAssetsForAlbum(album.id)
: <LocalAsset>[]; : <ImAsset>[];
await _localAlbumRepository.upsert(album, toUpsert: assets); await _localAlbumRepository.upsert(
album,
toUpsert: assets.toLocalAssets(),
);
_log.fine("Successfully added device album ${album.name}"); _log.fine("Successfully added device album ${album.name}");
} catch (e, s) { } catch (e, s) {
_log.warning("Error while adding device album", e, s); _log.warning("Error while adding device album", e, s);
@ -198,17 +201,13 @@ class DeviceSyncService {
return false; return false;
} }
// Get all assets that are modified after the last known modifiedTime final updatedTime =
final newAssets = await _albumMediaRepository.getAssetsForAlbum( (dbAlbum.updatedAt.millisecondsSinceEpoch ~/ 1000) + 1;
deviceAlbum.id, final newAssetsCount =
updateTimeCond: DateTimeFilter( await _nativeSyncApi.getAssetsCountSince(deviceAlbum.id, updatedTime);
min: dbAlbum.updatedAt.add(const Duration(seconds: 1)),
max: deviceAlbum.updatedAt,
),
);
// Early return if no new assets were found // Early return if no new assets were found
if (newAssets.isEmpty) { if (newAssetsCount == 0) {
_log.fine( _log.fine(
"No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}", "No new assets found despite album having changes. Proceeding to full sync for ${dbAlbum.name}",
); );
@ -216,14 +215,19 @@ class DeviceSyncService {
} }
// Check whether there is only addition or if there has been deletions // Check whether there is only addition or if there has been deletions
if (deviceAlbum.assetCount != dbAlbum.assetCount + newAssets.length) { if (deviceAlbum.assetCount != dbAlbum.assetCount + newAssetsCount) {
_log.fine("Local album has modifications. Proceeding to full sync"); _log.fine("Local album has modifications. Proceeding to full sync");
return false; return false;
} }
final newAssets = await _nativeSyncApi.getAssetsForAlbum(
deviceAlbum.id,
updatedTimeCond: updatedTime,
);
await _localAlbumRepository.upsert( await _localAlbumRepository.upsert(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
toUpsert: newAssets, toUpsert: newAssets.toLocalAssets(),
); );
return true; return true;
@ -239,7 +243,9 @@ class DeviceSyncService {
Future<bool> fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async { Future<bool> fullDiff(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
try { try {
final assetsInDevice = deviceAlbum.assetCount > 0 final assetsInDevice = deviceAlbum.assetCount > 0
? await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id) ? await _nativeSyncApi
.getAssetsForAlbum(deviceAlbum.id)
.then((a) => a.toLocalAssets())
: <LocalAsset>[]; : <LocalAsset>[];
final assetsInDb = dbAlbum.assetCount > 0 final assetsInDb = dbAlbum.assetCount > 0
? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id) ? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id)
@ -348,3 +354,22 @@ extension on Iterable<ImAlbum> {
).toList(); ).toList();
} }
} }
extension on Iterable<ImAsset> {
List<LocalAsset> toLocalAssets() {
return map(
(e) => LocalAsset(
id: e.id,
name: e.name,
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
createdAt: e.createdAt == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(e.createdAt! * 1000),
updatedAt: e.updatedAt == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(e.updatedAt! * 1000),
durationInSeconds: e.durationInSeconds,
),
).toList();
}
}

View File

@ -1,76 +0,0 @@
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'
as asset;
import 'package:photo_manager/photo_manager.dart';
class AlbumMediaRepository implements IAlbumMediaRepository {
const AlbumMediaRepository();
PMFilter _getAlbumFilter({
DateTimeFilter? createdTimeCond,
DateTimeFilter? updateTimeCond,
}) =>
FilterOptionGroup(
imageOption: const FilterOption(
// needTitle is expected to be slow on iOS but is required to fetch the asset title
needTitle: true,
sizeConstraint: SizeConstraint(ignoreSize: true),
),
videoOption: const FilterOption(
needTitle: true,
sizeConstraint: SizeConstraint(ignoreSize: true),
durationConstraint: DurationConstraint(allowNullable: true),
),
// This is needed to get the modified time of the album
containsPathModified: true,
createTimeCond: createdTimeCond == null
? DateTimeCond.def().copyWith(ignore: true)
: DateTimeCond(min: createdTimeCond.min, max: createdTimeCond.max),
updateTimeCond: updateTimeCond == null
? DateTimeCond.def().copyWith(ignore: true)
: DateTimeCond(min: updateTimeCond.min, max: updateTimeCond.max),
orders: [],
);
@override
Future<List<asset.LocalAsset>> getAssetsForAlbum(
String albumId, {
DateTimeFilter? updateTimeCond,
}) async {
final assetPathEntity = await AssetPathEntity.obtainPathFromProperties(
id: albumId,
optionGroup: _getAlbumFilter(updateTimeCond: updateTimeCond),
);
final assets = <AssetEntity>[];
int pageNumber = 0, lastPageCount = 0;
do {
final page = await assetPathEntity.getAssetListPaged(
page: pageNumber,
size: kFetchLocalAssetsBatchSize,
);
assets.addAll(page);
lastPageCount = page.length;
pageNumber++;
} while (lastPageCount == kFetchLocalAssetsBatchSize);
return Future.wait(assets.map((a) => a.toDto()));
}
}
extension on AssetEntity {
Future<asset.LocalAsset> toDto() async => asset.LocalAsset(
id: id,
name: title ?? await titleAsync,
type: switch (type) {
AssetType.other => asset.AssetType.other,
AssetType.image => asset.AssetType.image,
AssetType.video => asset.AssetType.video,
AssetType.audio => asset.AssetType.audio,
},
createdAt: createDateTime,
updatedAt: modifiedDateTime,
width: width,
height: height,
durationInSeconds: duration,
);
}

View File

@ -6,7 +6,6 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
class DriftLocalAlbumRepository extends DriftDatabaseRepository class DriftLocalAlbumRepository extends DriftDatabaseRepository
@ -187,19 +186,21 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
} }
@override @override
Future<void> processDelta(SyncDelta delta) { Future<void> processDelta({
required List<LocalAsset> updates,
required List<String> deletes,
required Map<String, List<String>> assetAlbums,
}) {
return _db.transaction(() async { return _db.transaction(() async {
await _deleteAssets(delta.deletes); await _deleteAssets(deletes);
await _upsertAssets(delta.updates.map((a) => a.toLocalAsset())); await _upsertAssets(updates);
// The ugly casting below is required for now because the generated code // The ugly casting below is required for now because the generated code
// casts the returned values from the platform during decoding them // casts the returned values from the platform during decoding them
// and iterating over them causes the type to be List<Object?> instead of // and iterating over them causes the type to be List<Object?> instead of
// List<String> // List<String>
await _db.batch((batch) async { await _db.batch((batch) async {
delta.assetAlbums assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
.cast<String, List<Object?>>()
.forEach((assetId, albumIds) {
batch.deleteWhere( batch.deleteWhere(
_db.localAlbumAssetEntity, _db.localAlbumAssetEntity,
(f) => (f) =>
@ -209,9 +210,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
}); });
}); });
await _db.batch((batch) async { await _db.batch((batch) async {
delta.assetAlbums assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
.cast<String, List<Object?>>()
.forEach((assetId, albumIds) {
batch.insertAll( batch.insertAll(
_db.localAlbumAssetEntity, _db.localAlbumAssetEntity,
albumIds.cast<String?>().nonNulls.map( albumIds.cast<String?>().nonNulls.map(
@ -339,24 +338,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
} }
} }
extension on ImAsset { extension on LocalAlbumEntityData {
LocalAsset toLocalAsset() {
return LocalAsset(
id: id,
name: name,
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
createdAt: createdAt == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(createdAt! * 1000),
updatedAt: updatedAt == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(updatedAt! * 1000),
durationInSeconds: durationInSeconds,
);
}
}
extension LocalAlbumEntityX on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) { LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum( return LocalAlbum(
id: id, id: id,

View File

@ -419,4 +419,60 @@ class NativeSyncApi {
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<ImAlbum>(); return (pigeonVar_replyList[0] as List<Object?>?)!.cast<ImAlbum>();
} }
} }
Future<int> getAssetsCountSince(String albumId, int timestamp) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$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, timestamp]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as int?)!;
}
}
Future<List<ImAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$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, updatedTimeCond]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<ImAsset>();
}
}
} }

View File

@ -1,13 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
import 'package:immich_mobile/infrastructure/repositories/album_media.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final albumMediaRepositoryProvider =
Provider<IAlbumMediaRepository>((ref) => const AlbumMediaRepository());
final localAlbumRepository = Provider<ILocalAlbumRepository>( final localAlbumRepository = Provider<ILocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
); );

View File

@ -28,7 +28,6 @@ final syncStreamRepositoryProvider = Provider(
final deviceSyncServiceProvider = Provider( final deviceSyncServiceProvider = Provider(
(ref) => DeviceSyncService( (ref) => DeviceSyncService(
albumMediaRepository: ref.watch(albumMediaRepositoryProvider),
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
storeService: ref.watch(storeServiceProvider), storeService: ref.watch(storeServiceProvider),

View File

@ -80,4 +80,10 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<ImAlbum> getAlbums(); List<ImAlbum> getAlbums();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
int getAssetsCountSince(String albumId, int timestamp);
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<ImAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
} }