feat: add cloud id during native sync (#20418)

* use adjustment time in iOS for hash reset

# Conflicts:
#	mobile/lib/infrastructure/repositories/local_album.repository.dart
#	mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart

* migration

* feat: sync cloudId and eTag on sync

* fixes fixes

* more fixes

* re-sync updated eTags

* add server version check & auto sync cloud ids on compatible servers

* fix test

* remove button from sync status page

* chore: modify for testing

* more changes

* chore: add commas in toString

* use cached provider in splash screen

* read upload service provider to prevent reset

* log errors from fetching cloud id mapping

* WIP: migrate cloud id - debug log

* ignore locked asset update

* bulk update metadata

* change log text

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2026-01-15 00:04:11 +05:30 committed by GitHub
parent ed9448a6ee
commit 9fa8de7baa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1048 additions and 90 deletions

View file

@ -2124,6 +2124,7 @@
"sync": "Sync", "sync": "Sync",
"sync_albums": "Sync albums", "sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync_cloud_ids": "Sync Cloud IDs",
"sync_local": "Sync Local", "sync_local": "Sync Local",
"sync_remote": "Sync Remote", "sync_remote": "Sync Remote",
"sync_status": "Sync Status", "sync_status": "Sync Status",

View file

@ -252,6 +252,40 @@ data class HashResult (
override fun hashCode(): Int = toList().hashCode() override fun hashCode(): Int = toList().hashCode()
} }
/** Generated class from Pigeon that represents data sent in messages. */
data class CloudIdResult (
val assetId: String,
val error: String? = null,
val cloudId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): CloudIdResult {
val assetId = pigeonVar_list[0] as String
val error = pigeonVar_list[1] as String?
val cloudId = pigeonVar_list[2] as String?
return CloudIdResult(assetId, error, cloudId)
}
}
fun toList(): List<Any?> {
return listOf(
assetId,
error,
cloudId,
)
}
override fun equals(other: Any?): Boolean {
if (other !is CloudIdResult) {
return false
}
if (this === other) {
return true
}
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class MessagesPigeonCodec : StandardMessageCodec() { private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) { return when (type) {
@ -275,6 +309,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
HashResult.fromList(it) HashResult.fromList(it)
} }
} }
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer) else -> super.readValueOfType(type, buffer)
} }
} }
@ -296,6 +335,10 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(132) stream.write(132)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is CloudIdResult -> {
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
} }
} }
@ -315,6 +358,7 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object { companion object {
/** The codec used by NativeSyncApi. */ /** The codec used by NativeSyncApi. */
@ -508,6 +552,23 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdsArg = args[0] as List<String>
val wrapped: List<Any?> = try {
listOf(api.getCloudIdForAssetIds(assetIdsArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }

View file

@ -14,6 +14,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@ -21,7 +22,6 @@ import kotlinx.coroutines.sync.withPermit
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
sealed class AssetResult { sealed class AssetResult {
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult() data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
@ -298,7 +298,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
var bytesRead: Int var bytesRead: Int
val buffer = ByteArray(HASH_BUFFER_SIZE) val buffer = ByteArray(HASH_BUFFER_SIZE)
while (inputStream.read(buffer).also { bytesRead = it } > 0) { while (inputStream.read(buffer).also { bytesRead = it } > 0) {
coroutineContext.ensureActive() currentCoroutineContext().ensureActive()
digest.update(buffer, 0, bytesRead) digest.update(buffer, 0, bytesRead)
} }
} ?: return HashResult(assetId, "Cannot open input stream for asset", null) } ?: return HashResult(assetId, "Cannot open input stream for asset", null)
@ -316,4 +316,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask?.cancel() hashTask?.cancel()
hashTask = null hashTask = null
} }
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList()
}
} }

Binary file not shown.

View file

@ -312,6 +312,39 @@ struct HashResult: Hashable {
} }
} }
/// Generated class from Pigeon that represents data sent in messages.
struct CloudIdResult: Hashable {
var assetId: String
var error: String? = nil
var cloudId: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> CloudIdResult? {
let assetId = pigeonVar_list[0] as! String
let error: String? = nilOrValue(pigeonVar_list[1])
let cloudId: String? = nilOrValue(pigeonVar_list[2])
return CloudIdResult(
assetId: assetId,
error: error,
cloudId: cloudId
)
}
func toList() -> [Any?] {
return [
assetId,
error,
cloudId,
]
}
static func == (lhs: CloudIdResult, rhs: CloudIdResult) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader { private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? { override func readValue(ofType type: UInt8) -> Any? {
switch type { switch type {
@ -323,6 +356,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return SyncDelta.fromList(self.readValue() as! [Any?]) return SyncDelta.fromList(self.readValue() as! [Any?])
case 132: case 132:
return HashResult.fromList(self.readValue() as! [Any?]) return HashResult.fromList(self.readValue() as! [Any?])
case 133:
return CloudIdResult.fromList(self.readValue() as! [Any?])
default: default:
return super.readValue(ofType: type) return super.readValue(ofType: type)
} }
@ -343,6 +378,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? HashResult { } else if let value = value as? HashResult {
super.writeByte(132) super.writeByte(132)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(133)
super.writeValue(value.toList())
} else { } else {
super.writeValue(value) super.writeValue(value)
} }
@ -377,6 +415,7 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@ -560,5 +599,22 @@ class NativeSyncApiSetup {
} else { } else {
getTrashedAssetsChannel.setMessageHandler(nil) getTrashedAssetsChannel.setMessageHandler(nil)
} }
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdsArg = args[0] as! [String]
do {
let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
} }
} }

View file

@ -390,4 +390,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return PHAsset.fetchAssets(in: album, options: options) return PHAsset.fetchAssets(in: album, options: options)
} }
} }
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] {
guard #available(iOS 16, *) else {
return assetIds.map { CloudIdResult(assetId: $0) }
}
var mappings: [CloudIdResult] = []
let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds)
for (key, value) in result {
switch value {
case .success(let cloudIdentifier):
let cloudId = cloudIdentifier.stringValue
// Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH"
if !cloudId.hasSuffix(":") {
mappings.append(CloudIdResult(assetId: key, cloudId: cloudId))
} else {
mappings.append(CloudIdResult(assetId: key, error: "Incomplete Cloud Id: \(cloudId)"))
}
case .failure(let error):
mappings.append(CloudIdResult(assetId: key, error: "Error getting Cloud Id: \(error.localizedDescription)"))
}
}
return mappings;
}
} }

View file

@ -4,6 +4,8 @@ const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1; const double downloadCompleted = -1;
const double downloadFailed = -2; const double downloadFailed = -2;
const String kMobileMetadataKey = "mobile-app";
// Number of log entries to retain on app start // Number of log entries to retain on app start
const int kLogTruncateLimit = 2000; const int kLogTruncateLimit = 2000;

View file

@ -0,0 +1,62 @@
enum RemoteAssetMetadataKey {
mobileApp("mobile-app");
final String key;
const RemoteAssetMetadataKey(this.key);
}
abstract class RemoteAssetMetadataValue {
const RemoteAssetMetadataValue();
Map<String, dynamic> toJson();
}
class RemoteAssetMetadataItem {
final RemoteAssetMetadataKey key;
final RemoteAssetMetadataValue value;
const RemoteAssetMetadataItem({required this.key, required this.value});
Map<String, Object?> toJson() {
return {'key': key.key, 'value': value};
}
}
class RemoteAssetMobileAppMetadata extends RemoteAssetMetadataValue {
final String? cloudId;
final String? createdAt;
final String? adjustmentTime;
final String? latitude;
final String? longitude;
const RemoteAssetMobileAppMetadata({
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, dynamic> toJson() {
final map = <String, Object?>{};
if (cloudId != null) {
map["iCloudId"] = cloudId;
}
if (createdAt != null) {
map["createdAt"] = createdAt;
}
if (adjustmentTime != null) {
map["adjustmentTime"] = adjustmentTime;
}
if (latitude != null) {
map["latitude"] = latitude;
}
if (longitude != null) {
map["longitude"] = longitude;
}
return map;
}
}

View file

@ -3,6 +3,7 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset { class LocalAsset extends BaseAsset {
final String id; final String id;
final String? remoteAssetId; final String? remoteAssetId;
final String? cloudId;
final int orientation; final int orientation;
final DateTime? adjustmentTime; final DateTime? adjustmentTime;
@ -12,6 +13,7 @@ class LocalAsset extends BaseAsset {
const LocalAsset({ const LocalAsset({
required this.id, required this.id,
String? remoteId, String? remoteId,
this.cloudId,
required super.name, required super.name,
super.checksum, super.checksum,
required super.type, required super.type,
@ -53,12 +55,14 @@ class LocalAsset extends BaseAsset {
width: ${width ?? "<NA>"}, width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"}, height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"}, durationInSeconds: ${durationInSeconds ?? "<NA>"},
remoteId: ${remoteId ?? "<NA>"} remoteId: ${remoteId ?? "<NA>"},
cloudId: ${cloudId ?? "<NA>"},
checksum: ${checksum ?? "<NA>"},
isFavorite: $isFavorite, isFavorite: $isFavorite,
orientation: $orientation, orientation: $orientation,
adjustmentTime: $adjustmentTime, adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"}, latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"}, longitude: ${longitude ?? "<NA>"},
}'''; }''';
} }
@ -69,6 +73,7 @@ class LocalAsset extends BaseAsset {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return super == other && return super == other &&
id == other.id && id == other.id &&
cloudId == other.cloudId &&
orientation == other.orientation && orientation == other.orientation &&
adjustmentTime == other.adjustmentTime && adjustmentTime == other.adjustmentTime &&
latitude == other.latitude && latitude == other.latitude &&
@ -88,6 +93,7 @@ class LocalAsset extends BaseAsset {
LocalAsset copyWith({ LocalAsset copyWith({
String? id, String? id,
String? remoteId, String? remoteId,
String? cloudId,
String? name, String? name,
String? checksum, String? checksum,
AssetType? type, AssetType? type,
@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset {
return LocalAsset( return LocalAsset(
id: id ?? this.id, id: id ?? this.id,
remoteId: remoteId ?? this.remoteId, remoteId: remoteId ?? this.remoteId,
cloudId: cloudId ?? this.cloudId,
name: name ?? this.name, name: name ?? this.name,
checksum: checksum ?? this.checksum, checksum: checksum ?? this.checksum,
type: type ?? this.type, type: type ?? this.type,

View file

@ -40,6 +40,9 @@ class HashService {
_log.info("Starting hashing of assets"); _log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
try { try {
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
await _migrateHashes();
// Sorted by backupSelection followed by isCloud // Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums(); final localAlbums = await _localAlbumRepository.getBackupAlbums();
@ -75,6 +78,15 @@ class HashService {
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); _log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
} }
Future<void> _migrateHashes() async {
final hashMappings = await _localAssetRepository.getHashMappingFromCloudId();
if (hashMappings.isEmpty) {
return;
}
await _localAssetRepository.updateHashes(hashMappings);
}
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB /// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table /// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB. /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.

View file

@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.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/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
@ -18,6 +19,7 @@ import 'package:logging/logging.dart';
class LocalSyncService { class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager; final LocalFilesManagerRepository _localFilesManager;
@ -26,11 +28,13 @@ class LocalSyncService {
LocalSyncService({ LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager, required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository, required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager, _localFilesManager = localFilesManager,
_storageRepository = storageRepository, _storageRepository = storageRepository,
@ -47,6 +51,12 @@ class LocalSyncService {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing"); _log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
} }
} }
if (CurrentPlatform.isIOS) {
final assets = await _localAssetRepository.getEmptyCloudIdAssets();
await _mapIosCloudIds(assets);
}
if (full || await _nativeSyncApi.shouldFullSync()) { if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}"); _log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync(); return await fullSync();
@ -63,8 +73,9 @@ class LocalSyncService {
final deviceAlbums = await _nativeSyncApi.getAlbums(); final deviceAlbums = await _nativeSyncApi.getAlbums();
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
final newAssets = delta.updates.toLocalAssets();
await _localAlbumRepository.processDelta( await _localAlbumRepository.processDelta(
updates: delta.updates.toLocalAssets(), updates: newAssets,
deletes: delta.deletes, deletes: delta.deletes,
assetAlbums: delta.assetAlbums, assetAlbums: delta.assetAlbums,
); );
@ -92,6 +103,8 @@ class LocalSyncService {
} }
await updateAlbum(dbAlbum, album); await updateAlbum(dbAlbum, album);
} }
await _mapIosCloudIds(newAssets);
} }
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();
} catch (e, s) { } catch (e, s) {
@ -130,9 +143,12 @@ class LocalSyncService {
try { try {
_log.fine("Adding device album ${album.name}"); _log.fine("Adding device album ${album.name}");
final assets = album.assetCount > 0 ? await _nativeSyncApi.getAssetsForAlbum(album.id) : <PlatformAsset>[]; final assets = album.assetCount > 0
? await _nativeSyncApi.getAssetsForAlbum(album.id).then((a) => a.toLocalAssets())
: <LocalAsset>[];
await _localAlbumRepository.upsert(album, toUpsert: assets.toLocalAssets()); await _localAlbumRepository.upsert(album, toUpsert: assets);
await _mapIosCloudIds(assets);
_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);
@ -202,13 +218,16 @@ class LocalSyncService {
return false; return false;
} }
final newAssets = await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime); final newAssets = await _nativeSyncApi
.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime)
.then((a) => a.toLocalAssets());
await _localAlbumRepository.upsert( await _localAlbumRepository.upsert(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
toUpsert: newAssets.toLocalAssets(), toUpsert: newAssets,
); );
await _mapIosCloudIds(newAssets);
return true; return true;
} catch (e, s) { } catch (e, s) {
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s); _log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
@ -240,6 +259,7 @@ class LocalSyncService {
if (dbAlbum.assetCount == 0) { if (dbAlbum.assetCount == 0) {
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB."); _log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice); await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
await _mapIosCloudIds(assetsInDevice);
return true; return true;
} }
@ -277,6 +297,7 @@ class LocalSyncService {
} }
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete); await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
await _mapIosCloudIds(assetsToUpsert);
return true; return true;
} catch (e, s) { } catch (e, s) {
@ -285,6 +306,29 @@ class LocalSyncService {
return true; return true;
} }
Future<void> _mapIosCloudIds(List<LocalAsset> assets) async {
if (!CurrentPlatform.isIOS || assets.isEmpty) {
return;
}
final assetIds = assets.map((a) => a.id).toList();
final cloudMapping = <String, String>{};
final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
for (int i = 0; i < cloudIds.length; i++) {
final cloudIdResult = cloudIds[i];
if (cloudIdResult.cloudId != null) {
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
} else {
final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId);
_log.fine(
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}",
);
}
}
await _localAlbumRepository.updateCloudMapping(cloudMapping);
}
bool _assetsEqual(LocalAsset a, LocalAsset b) { bool _assetsEqual(LocalAsset a, LocalAsset b) {
if (CurrentPlatform.isAndroid) { if (CurrentPlatform.isAndroid) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) && return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&

View file

@ -118,6 +118,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsV1(data.cast()); return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1: case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast()); return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1:
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
case SyncEntityType.partnerAssetV1: case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetBackfillV1: case SyncEntityType.partnerAssetBackfillV1:

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
import 'package:immich_mobile/domain/utils/sync_linked_album.dart'; import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart'; import 'package:immich_mobile/utils/isolate.dart';
@ -22,8 +23,13 @@ class BackgroundSyncManager {
final SyncCallback? onHashingComplete; final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError; final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask; Cancelable<bool?>? _syncTask;
Cancelable<void>? _syncWebsocketTask; Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask; Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask; Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask; Cancelable<void>? _hashTask;
@ -38,6 +44,9 @@ class BackgroundSyncManager {
this.onHashingStart, this.onHashingStart,
this.onHashingComplete, this.onHashingComplete,
this.onHashingError, this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
}); });
Future<void> cancel() async { Future<void> cancel() async {
@ -55,6 +64,12 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel(); _syncWebsocketTask?.cancel();
_syncWebsocketTask = null; _syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) { if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future); futures.add(_linkedAlbumSyncTask!.future);
} }
@ -121,7 +136,6 @@ class BackgroundSyncManager {
}); });
} }
// No need to cancel the task, as it can also be run when the user logs out
Future<void> hashAssets() { Future<void> hashAssets() {
if (_hashTask != null) { if (_hashTask != null) {
return _hashTask!.future; return _hashTask!.future;
@ -192,6 +206,25 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask = null; _linkedAlbumSyncTask = null;
}); });
} }
Future<void> syncCloudIds() {
if (_cloudIdSyncTask != null) {
return _cloudIdSyncTask!.future;
}
onCloudIdSyncStart?.call();
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
return _cloudIdSyncTask!
.whenComplete(() {
onCloudIdSyncComplete?.call();
_cloudIdSyncTask = null;
})
.catchError((error) {
onCloudIdSyncError?.call(error.toString());
_cloudIdSyncTask = null;
});
}
} }
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle( Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View file

@ -0,0 +1,166 @@
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AssetVisibility;
Future<void> syncCloudIds(ProviderContainer ref) async {
if (!CurrentPlatform.isIOS) {
return;
}
final db = ref.read(driftProvider);
// Populate cloud IDs for local assets that don't have one yet
await _populateCloudIds(db);
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
if (!canUpdateMetadata) {
Logger(
'migrateCloudIds',
).fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
return;
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
await ref.read(syncStreamServiceProvider).sync();
} catch (e, s) {
Logger('migrateCloudIds').fine('Failed to complete remote sync before cloudId migration.', e, s);
return;
}
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
Logger('migrateCloudIds').warning('Current user is null. Aborting cloudId migration.');
return;
}
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
final assetApi = ref.read(apiServiceProvider).assetsApi;
if (canBulkUpdateMetadata) {
await _bulkUpdateCloudIds(assetApi, mappingsToUpdate);
return;
}
await _sequentialUpdateCloudIds(assetApi, mappingsToUpdate);
}
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
for (final mapping in mappings) {
final item = AssetMetadataUpsertItemDto(
key: kMobileMetadataKey,
value: RemoteAssetMobileAppMetadata(
cloudId: mapping.localAsset.cloudId,
createdAt: mapping.localAsset.createdAt.toIso8601String(),
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
latitude: mapping.localAsset.latitude?.toString(),
longitude: mapping.localAsset.longitude?.toString(),
),
);
try {
await assetsApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [item]));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack);
}
}
}
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
const batchSize = 10000;
for (int i = 0; i < mappings.length; i += batchSize) {
final endIndex = (i + batchSize > mappings.length) ? mappings.length : i + batchSize;
final batch = mappings.sublist(i, endIndex);
final items = <AssetMetadataBulkUpsertItemDto>[];
for (final mapping in batch) {
items.add(
AssetMetadataBulkUpsertItemDto(
assetId: mapping.remoteAssetId,
key: kMobileMetadataKey,
value: RemoteAssetMobileAppMetadata(
cloudId: mapping.localAsset.cloudId,
createdAt: mapping.localAsset.createdAt.toIso8601String(),
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
latitude: mapping.localAsset.latitude?.toString(),
longitude: mapping.localAsset.longitude?.toString(),
),
),
);
}
try {
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
}
}
}
Future<void> _populateCloudIds(Drift drift) async {
final query = drift.localAssetEntity.selectOnly()
..addColumns([drift.localAssetEntity.id])
..where(drift.localAssetEntity.iCloudId.isNull());
final ids = await query.map((row) => row.read(drift.localAssetEntity.id)!).get();
final cloudMapping = <String, String>{};
final cloudIds = await NativeSyncApi().getCloudIdForAssetIds(ids);
for (int i = 0; i < cloudIds.length; i++) {
final cloudIdResult = cloudIds[i];
if (cloudIdResult.cloudId != null) {
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
} else {
Logger('migrateCloudIds').fine(
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}. Error: ${cloudIdResult.error ?? "unknown"}",
);
}
}
await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping);
}
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId) async {
final query =
drift.remoteAssetEntity.select().join([
leftOuterJoin(
drift.localAssetEntity,
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
),
leftOuterJoin(
drift.remoteAssetCloudIdEntity,
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
useColumns: false,
),
])..where(
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
drift.localAssetEntity.id.isNotNull() &
drift.localAssetEntity.iCloudId.isNotNull() &
drift.remoteAssetEntity.ownerId.equals(userId) &
// Skip locked assets as we cannot update them without unlocking first
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
((drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime)) &
(drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude)) &
(drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude)) &
(drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)))),
);
return query.map((row) {
return (
remoteAssetId: row.read(drift.remoteAssetEntity.id)!,
localAsset: row.readTable(drift.localAssetEntity).toDto(),
);
}).get();
}

View file

@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity(); const LocalAssetEntity();
@ -16,6 +17,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get orientation => integer().withDefault(const Constant(0))(); IntColumn get orientation => integer().withDefault(const Constant(0))();
TextColumn get iCloudId => text().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()(); DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()(); RealColumn get latitude => real().nullable()();
@ -43,5 +46,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
adjustmentTime: adjustmentTime, adjustmentTime: adjustmentTime,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
cloudId: iCloudId,
); );
} }

View file

@ -21,7 +21,11 @@ SELECT
rae.owner_id, rae.owner_id,
rae.live_photo_video_id, rae.live_photo_video_id,
0 as orientation, 0 as orientation,
rae.stack_id rae.stack_id,
NULL as i_cloud_id,
NULL as latitude,
NULL as longitude,
NULL as adjustmentTime
FROM FROM
remote_asset_entity rae remote_asset_entity rae
LEFT JOIN LEFT JOIN
@ -53,7 +57,11 @@ SELECT
NULL as owner_id, NULL as owner_id,
NULL as live_photo_video_id, NULL as live_photo_video_id,
lae.orientation, lae.orientation,
NULL as stack_id NULL as stack_id,
lae.i_cloud_id,
lae.latitude,
lae.longitude,
lae.adjustment_time
FROM FROM
local_asset_entity lae local_asset_entity lae
WHERE NOT EXISTS ( WHERE NOT EXISTS (

View file

@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get cloudId => text().unique().nullable()();
DateTimeColumn get createdAt => dateTime().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();
@override
Set<Column> get primaryKey => {assetId};
}

View file

@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
@ -57,6 +58,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
RemoteAlbumEntity, RemoteAlbumEntity,
RemoteAlbumAssetEntity, RemoteAlbumAssetEntity,
RemoteAlbumUserEntity, RemoteAlbumUserEntity,
RemoteAssetCloudIdEntity,
MemoryEntity, MemoryEntity,
MemoryAssetEntity, MemoryAssetEntity,
StackEntity, StackEntity,
@ -95,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 15; int get schemaVersion => 16;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -193,6 +195,12 @@ class Drift extends $Drift implements IDatabaseRepository {
from14To15: (m, v15) async { from14To15: (m, v15) async {
await m.addColumn(v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity.source); await m.addColumn(v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity.source);
}, },
from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
), ),
); );

View file

@ -246,6 +246,25 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
} }
Future<void> updateCloudMapping(Map<String, String> cloudMapping) {
if (cloudMapping.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final entry in cloudMapping.entries) {
final assetId = entry.key;
final cloudId = entry.value;
batch.update(
_db.localAssetEntity,
LocalAssetEntityCompanion(iCloudId: Value(cloudId)),
where: (f) => f.id.equals(assetId),
);
}
});
}
Future<void> Function(Iterable<LocalAsset>) get _upsertAssets => Future<void> Function(Iterable<LocalAsset>) get _upsertAssets =>
CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid; CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid;

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@ -172,4 +174,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final rows = await query.get(); final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList(); return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
} }
Future<List<LocalAsset>> getEmptyCloudIdAssets() {
final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull());
return query.map((row) => row.toDto()).get();
}
Future<Map<String, String>> getHashMappingFromCloudId() async {
final query =
_db.localAssetEntity.selectOnly().join([
leftOuterJoin(
_db.remoteAssetCloudIdEntity,
_db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum])
..where(
_db.remoteAssetCloudIdEntity.cloudId.isNotNull() &
_db.localAssetEntity.checksum.isNull() &
((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) &
(_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) &
(_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) &
(_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))),
);
final mapping = await query
.map(
(row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!),
)
.get();
return {for (final entry in mapping) entry.assetId: entry.checksum};
}
} }

View file

@ -45,6 +45,7 @@ class SyncApiRepository {
SyncRequestType.usersV1, SyncRequestType.usersV1,
SyncRequestType.assetsV1, SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1, SyncRequestType.assetExifsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1, SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1, SyncRequestType.partnerAssetExifsV1,
@ -148,6 +149,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.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/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';
@ -18,6 +19,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
@ -55,6 +57,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.authUserEntity.deleteAll(); await _db.authUserEntity.deleteAll();
await _db.userEntity.deleteAll(); await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll(); await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
}); });
await _db.customStatement('PRAGMA foreign_keys = ON'); await _db.customStatement('PRAGMA foreign_keys = ON');
}); });
@ -272,6 +275,50 @@ class SyncStreamRepository extends DriftDatabaseRepository {
} }
} }
Future<void> deleteAssetsMetadataV1(Iterable<SyncAssetMetadataDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final metadata in data) {
if (metadata.key == kMobileMetadataKey) {
batch.deleteWhere(_db.remoteAssetCloudIdEntity, (row) => row.assetId.equals(metadata.assetId));
}
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetsMetadataV1', error, stack);
rethrow;
}
}
Future<void> updateAssetsMetadataV1(Iterable<SyncAssetMetadataV1> data) async {
try {
await _db.batch((batch) {
for (final metadata in data) {
if (metadata.key == kMobileMetadataKey) {
final map = metadata.value as Map<String, Object?>;
final companion = RemoteAssetCloudIdEntityCompanion(
cloudId: Value(map['iCloudId']?.toString()),
createdAt: Value(map['createdAt'] != null ? DateTime.parse(map['createdAt'] as String) : null),
adjustmentTime: Value(
map['adjustmentTime'] != null ? DateTime.parse(map['adjustmentTime'] as String) : null,
),
latitude: Value(map['latitude'] != null ? (double.tryParse(map['latitude'] as String)) : null),
longitude: Value(map['longitude'] != null ? (double.tryParse(map['longitude'] as String)) : null),
);
batch.insert(
_db.remoteAssetCloudIdEntity,
companion.copyWith(assetId: Value(metadata.assetId)),
onConflict: DoUpdate((_) => companion),
);
}
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetsMetadataV1', error, stack);
rethrow;
}
}
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async { Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.batch((batch) {

View file

@ -84,6 +84,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite, isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds, durationInSeconds: row.durationInSeconds,
orientation: row.orientation, orientation: row.orientation,
cloudId: row.iCloudId,
latitude: row.latitude,
longitude: row.longitude,
adjustmentTime: row.adjustmentTime,
), ),
) )
.get(); .get();

View file

@ -10,4 +10,8 @@ class ServerVersion extends SemVer {
} }
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_); ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
}
} }

View file

@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -50,7 +49,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final accessToken = Store.tryGet(StoreKey.accessToken); final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) { if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier); final backupProvider = ref.read(driftBackupProvider.notifier);
@ -60,7 +58,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
(_) async { (_) async {
try { try {
wsProvider.connect(); wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false; bool syncSuccess = false;
@ -75,6 +72,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider); _resumeBackup(backupProvider);
}), }),
_resumeBackup(backupProvider), _resumeBackup(backupProvider),
backgroundManager.syncCloudIds(),
]); ]);
} else { } else {
await backgroundManager.hashAssets(); await backgroundManager.hashAssets();

Binary file not shown.

View file

@ -131,6 +131,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id); final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', '))); properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
properties.add(_PropertyItem(label: 'Cloud ID', value: asset.cloudId));
properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString())); properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString()));
} }
properties.add( properties.add(

View file

@ -160,6 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup(); _resumeBackup();
}), }),
_resumeBackup(), _resumeBackup(),
backgroundManager.syncCloudIds(),
]); ]);
} else { } else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets"); await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View file

@ -14,19 +14,19 @@ import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/hash.dart'; import 'package:immich_mobile/utils/hash.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) { final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier( return AuthNotifier(
ref.watch(authServiceProvider), ref.watch(authServiceProvider),
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(uploadServiceProvider),
ref.watch(secureStorageServiceProvider), ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider), ref.watch(widgetServiceProvider),
ref,
); );
}); });
@ -34,9 +34,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
final AuthService _authService; final AuthService _authService;
final ApiService _apiService; final ApiService _apiService;
final UserService _userService; final UserService _userService;
final UploadService _uploadService;
final SecureStorageService _secureStorageService; final SecureStorageService _secureStorageService;
final WidgetService _widgetService; final WidgetService _widgetService;
final Ref _ref;
final _log = Logger("AuthenticationNotifier"); final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7); static const Duration _timeoutDuration = Duration(seconds: 7);
@ -45,9 +45,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
this._authService, this._authService,
this._apiService, this._apiService,
this._userService, this._userService,
this._uploadService,
this._secureStorageService, this._secureStorageService,
this._widgetService, this._widgetService,
this._ref,
) : super( ) : super(
const AuthState( const AuthState(
deviceId: "", deviceId: "",
@ -87,7 +87,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _widgetService.clearCredentials(); await _widgetService.clearCredentials();
await _authService.logout(); await _authService.logout();
await _uploadService.cancelBackup(); await _ref.read(uploadServiceProvider).cancelBackup();
} finally { } finally {
await _cleanUp(); await _cleanUp();
} }

View file

@ -28,6 +28,9 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onHashingStart: syncStatusNotifier.startHashJob, onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob, onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob, onHashingError: syncStatusNotifier.errorHashJob,
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
); );
ref.onDispose(manager.cancel); ref.onDispose(manager.cancel);
return manager; return manager;

View file

@ -32,6 +32,7 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.
final localSyncServiceProvider = Provider( final localSyncServiceProvider = Provider(
(ref) => LocalSyncService( (ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider), localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider), storageRepository: ref.watch(storageRepositoryProvider),

View file

@ -32,10 +32,11 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
final ServerInfoService _serverInfoService; final ServerInfoService _serverInfoService;
final _log = Logger("ServerInfoNotifier"); final _log = Logger("ServerInfoNotifier");
Future<void> getServerInfo() async { Future<ServerInfo> getServerInfo() async {
await getServerVersion(); await getServerVersion();
await getServerFeatures(); await getServerFeatures();
await getServerConfig(); await getServerConfig();
return state;
} }
Future<void> getServerVersion() async { Future<void> getServerVersion() async {

View file

@ -21,6 +21,7 @@ class SyncStatusState {
final SyncStatus remoteSyncStatus; final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus; final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus; final SyncStatus hashJobStatus;
final SyncStatus cloudIdSyncStatus;
final String? errorMessage; final String? errorMessage;
@ -28,6 +29,7 @@ class SyncStatusState {
this.remoteSyncStatus = SyncStatus.idle, this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle, this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle, this.hashJobStatus = SyncStatus.idle,
this.cloudIdSyncStatus = SyncStatus.idle,
this.errorMessage, this.errorMessage,
}); });
@ -35,12 +37,14 @@ class SyncStatusState {
SyncStatus? remoteSyncStatus, SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus, SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus, SyncStatus? hashJobStatus,
SyncStatus? cloudIdSyncStatus,
String? errorMessage, String? errorMessage,
}) { }) {
return SyncStatusState( return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus, localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus, hashJobStatus: hashJobStatus ?? this.hashJobStatus,
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
} }
@ -48,6 +52,7 @@ class SyncStatusState {
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing; bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing; bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@ -56,11 +61,12 @@ class SyncStatusState {
other.remoteSyncStatus == remoteSyncStatus && other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus && other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus && other.hashJobStatus == hashJobStatus &&
other.cloudIdSyncStatus == cloudIdSyncStatus &&
other.errorMessage == errorMessage; other.errorMessage == errorMessage;
} }
@override @override
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage); int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
} }
class SyncStatusNotifier extends Notifier<SyncStatusState> { class SyncStatusNotifier extends Notifier<SyncStatusState> {
@ -71,6 +77,7 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
remoteSyncStatus: SyncStatus.idle, remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle, localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle, hashJobStatus: SyncStatus.idle,
cloudIdSyncStatus: SyncStatus.idle,
); );
} }
@ -109,6 +116,18 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void startHashJob() => setHashJobStatus(SyncStatus.syncing); void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success); void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error); void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
///
/// Cloud ID Sync Job
///
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
}
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
} }
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new); final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);

View file

@ -7,6 +7,7 @@ import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.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/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@ -14,10 +15,12 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@ -34,6 +37,7 @@ final uploadServiceProvider = Provider((ref) {
ref.watch(localAssetRepository), ref.watch(localAssetRepository),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
ref.watch(serverInfoProvider),
); );
ref.onDispose(service.dispose); ref.onDispose(service.dispose);
@ -48,6 +52,7 @@ class UploadService {
this._localAssetRepository, this._localAssetRepository,
this._appSettingsService, this._appSettingsService,
this._assetMediaRepository, this._assetMediaRepository,
this._serverInfo,
) { ) {
_uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback;
@ -59,6 +64,7 @@ class UploadService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final AppSettingsService _appSettingsService; final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final ServerInfo _serverInfo;
final Logger _logger = Logger('UploadService'); final Logger _logger = Logger('UploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast(); final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
@ -352,6 +358,10 @@ class UploadService {
priority: priority, priority: priority,
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi, requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
); );
} }
@ -383,6 +393,10 @@ class UploadService {
priority: 0, // Highest priority to get upload immediately priority: 0, // Highest priority to get upload immediately
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi, requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
); );
} }
@ -410,6 +424,10 @@ class UploadService {
int? priority, int? priority,
bool? isFavorite, bool? isFavorite,
bool requiresWiFi = true, bool requiresWiFi = true,
String? cloudId,
String? adjustmentTime,
String? latitude,
String? longitude,
}) async { }) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString(); final url = Uri.parse('$serverEndpoint/assets').toString();
@ -425,6 +443,20 @@ class UploadService {
'isFavorite': isFavorite?.toString() ?? 'false', 'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0', 'duration': '0',
if (fields != null) ...fields, if (fields != null) ...fields,
// Include cloudId and eTag in metadata if available and server version supports it
if (CurrentPlatform.isIOS && cloudId != null && _serverInfo.serverVersion.isAtLeast(major: 2, minor: 4))
'metadata': jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: cloudId,
createdAt: createdAt.toIso8601String(),
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
),
]),
}; };
return UploadTask( return UploadTask(

View file

@ -194,7 +194,7 @@ class _SyncStatusIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return switch (status) { return switch (status) {
SyncStatus.idle => const Icon(Icons.pause_circle_outline_rounded), SyncStatus.idle => const SizedBox.shrink(),
SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)), SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)),
SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green), SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green),
SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error), SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error),

View file

@ -90,6 +90,14 @@ class HashResult {
const HashResult({required this.assetId, this.error, this.hash}); const HashResult({required this.assetId, this.error, this.hash});
} }
class CloudIdResult {
final String assetId;
final String? error;
final String? cloudId;
const CloudIdResult({required this.assetId, this.error, this.cloudId});
}
@HostApi() @HostApi()
abstract class NativeSyncApi { abstract class NativeSyncApi {
bool shouldFullSync(); bool shouldFullSync();
@ -121,4 +129,7 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets(); Map<String, List<PlatformAsset>> getTrashedAssets();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
} }

View file

@ -33,6 +33,7 @@ void main() {
registerFallbackValue(LocalAssetStub.image1); registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String, String>{}); registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
}); });

View file

@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.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/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
@ -25,6 +26,7 @@ import '../../repository.mocks.dart';
void main() { void main() {
late LocalSyncService sut; late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository; late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late LocalFilesManagerRepository mockLocalFilesManager; late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository; late StorageRepository mockStorageRepository;
@ -47,6 +49,7 @@ void main() {
setUp(() async { setUp(() async {
mockLocalAlbumRepository = MockLocalAlbumRepository(); mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository(); mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository(); mockStorageRepository = MockStorageRepository();
@ -66,6 +69,7 @@ void main() {
sut = LocalSyncService( sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository, localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository, trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
localFilesManager: mockLocalFilesManager, localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository, storageRepository: mockStorageRepository,

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,22 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/models/server_info/server_features.model.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@ -16,8 +24,29 @@ import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart'; import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart'; import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart'; import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../mocks/asset_entity.mock.dart'; import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
// Test ServerInfo stub
const _serverInfo = ServerInfo(
serverVersion: ServerVersion(major: 2, minor: 4, patch: 0),
latestVersion: ServerVersion(major: 2, minor: 4, patch: 0),
serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false),
serverConfig: ServerConfig(
trashDays: 30,
oauthButtonText: 'Login with OAuth',
externalDomain: '',
mapDarkStyleUrl: '',
mapLightStyleUrl: '',
),
serverDiskInfo: ServerDiskInfo(
diskAvailable: '100GB',
diskSize: '500GB',
diskUse: '400GB',
diskUsagePercentage: 80.0,
),
versionStatus: VersionStatus.upToDate,
);
void main() { void main() {
late UploadService sut; late UploadService sut;
@ -62,6 +91,7 @@ void main() {
mockLocalAssetRepository, mockLocalAssetRepository,
mockAppSettingsService, mockAppSettingsService,
mockAssetMediaRepository, mockAssetMediaRepository,
_serverInfo,
); );
mockUploadRepository.onUploadStatus = (_) {}; mockUploadRepository.onUploadStatus = (_) {};
@ -165,4 +195,227 @@ void main() {
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
}); });
}); });
group('Server Info - cloudId and eTag metadata', () {
test('should include cloudId and eTag metadata on iOS when server version is 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
adjustmentTime: DateTime(2026, 1, 2),
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
expect(metadata[0]['key'], equals('mobile-app'));
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-123'));
expect(metadata[0]['value']['createdAt'], isNotNull);
expect(metadata[0]['value']['adjustmentTime'], isNotNull);
expect(metadata[0]['value']['latitude'], isNotNull);
expect(metadata[0]['value']['longitude'], isNotNull);
});
test('should NOT include metadata on iOS when server version is below 2.4', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV23 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo.copyWith(
serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
),
);
addTearDown(() => sutWithV23.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV23.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should NOT include metadata on Android regardless of server version', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutAndroid = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutAndroid.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should NOT include metadata when cloudId is null even on iOS with server 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithoutCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: null, // No cloudId
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithoutCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should include metadata for live photos with cloudId on iOS 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-livephoto-id',
name: 'livephoto.heic',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-livephoto',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/livephoto.heic');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic');
final task = await sutWithV24.getLivePhotoUploadTask(assetWithCloudId, 'video-123');
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123'));
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
expect(metadata[0]['key'], equals('mobile-app'));
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto'));
});
});
} }