mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
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:
parent
ed9448a6ee
commit
9fa8de7baa
46 changed files with 1048 additions and 90 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
mobile/drift_schemas/main/drift_schema_v16.json
generated
Normal file
BIN
mobile/drift_schemas/main/drift_schema_v16.json
generated
Normal file
Binary file not shown.
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
62
mobile/lib/domain/models/asset/asset_metadata.model.dart
Normal file
62
mobile/lib/domain/models/asset/asset_metadata.model.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) &&
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
166
mobile/lib/domain/utils/migrate_cloud_ids.dart
Normal file
166
mobile/lib/domain/utils/migrate_cloud_ids.dart
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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 (
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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};
|
||||||
|
}
|
||||||
BIN
mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart
generated
Normal file
BIN
mobile/lib/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart
generated
Normal file
Binary file not shown.
|
|
@ -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);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
BIN
mobile/lib/platform/native_sync_api.g.dart
generated
BIN
mobile/lib/platform/native_sync_api.g.dart
generated
Binary file not shown.
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
BIN
mobile/test/drift/main/generated/schema.dart
generated
BIN
mobile/test/drift/main/generated/schema.dart
generated
Binary file not shown.
BIN
mobile/test/drift/main/generated/schema_v16.dart
generated
Normal file
BIN
mobile/test/drift/main/generated/schema_v16.dart
generated
Normal file
Binary file not shown.
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue