Compare commits

...

1 Commits

Author SHA1 Message Date
Santo Shakil 49a02ab2d9 feat(mobile): stack original + edited photo on ios
Detect an iOS edit, upload the unedited original, and stack the edited
version on top of it. Reverting in Photos flips the stack cover back to
the original and keeps the edits. Adds an optional stackParentId field to
the asset upload on the server.
2026-05-22 02:13:26 +06:00
46 changed files with 28885 additions and 65 deletions
@@ -207,6 +207,18 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
} }
} }
enum class EditState(val raw: Int) {
NOT_EDITED(0),
EDITED(1),
UNKNOWN(2);
companion object {
fun ofRaw(raw: Int): EditState? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
data class PlatformAsset ( data class PlatformAsset (
val id: String, val id: String,
@@ -472,6 +484,52 @@ data class CloudIdResult (
return result return result
} }
} }
/** Generated class from Pigeon that represents data sent in messages. */
data class BaseResource (
val path: String,
val sha1: String,
val sizeBytes: Long,
val mimeType: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
val path = pigeonVar_list[0] as String
val sha1 = pigeonVar_list[1] as String
val sizeBytes = pigeonVar_list[2] as Long
val mimeType = pigeonVar_list[3] as String
return BaseResource(path, sha1, sizeBytes, mimeType)
}
}
fun toList(): List<Any?> {
return listOf(
path,
sha1,
sizeBytes,
mimeType,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as BaseResource
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1) && MessagesPigeonUtils.deepEquals(this.sizeBytes, other.sizeBytes) && MessagesPigeonUtils.deepEquals(this.mimeType, other.mimeType)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sizeBytes)
result = 31 * result + MessagesPigeonUtils.deepHash(this.mimeType)
return result
}
}
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) {
@@ -481,30 +539,40 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
} }
} }
130.toByte() -> { 130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as Long?)?.let {
PlatformAsset.fromList(it) EditState.ofRaw(it.toInt())
} }
} }
131.toByte() -> { 131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it) PlatformAsset.fromList(it)
} }
} }
132.toByte() -> { 132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it) PlatformAlbum.fromList(it)
} }
} }
133.toByte() -> { 133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it) SyncDelta.fromList(it)
} }
} }
134.toByte() -> { 134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it) CloudIdResult.fromList(it)
} }
} }
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BaseResource.fromList(it)
}
}
else -> super.readValueOfType(type, buffer) else -> super.readValueOfType(type, buffer)
} }
} }
@@ -514,26 +582,34 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(129) stream.write(129)
writeValue(stream, value.raw.toLong()) writeValue(stream, value.raw.toLong())
} }
is PlatformAsset -> { is EditState -> {
stream.write(130) stream.write(130)
writeValue(stream, value.toList()) writeValue(stream, value.raw.toLong())
} }
is PlatformAlbum -> { is PlatformAsset -> {
stream.write(131) stream.write(131)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is SyncDelta -> { is PlatformAlbum -> {
stream.write(132) stream.write(132)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is HashResult -> { is SyncDelta -> {
stream.write(133) stream.write(133)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is CloudIdResult -> { is HashResult -> {
stream.write(134) stream.write(134)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is CloudIdResult -> {
stream.write(135)
writeValue(stream, value.toList())
}
is BaseResource -> {
stream.write(136)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
} }
} }
@@ -554,6 +630,8 @@ interface NativeSyncApi {
fun cancelHashing() fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
companion object { companion object {
/** The codec used by NativeSyncApi. */ /** The codec used by NativeSyncApi. */
@@ -764,6 +842,48 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }
@@ -453,4 +453,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> { fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList() return emptyList()
} }
// Android has no Photos-style edit original to stack; iOS-only.
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
callback(Result.success(null))
}
// iOS-only; Android assets never carry a Photos-style edit.
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
callback(Result.success(EditState.NOT_EDITED))
}
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+118 -10
View File
@@ -183,6 +183,12 @@ enum PlatformAssetPlaybackStyle: Int {
case videoLooping = 5 case videoLooping = 5
} }
enum EditState: Int {
case notEdited = 0
case edited = 1
case unknown = 2
}
/// Generated class from Pigeon that represents data sent in messages. /// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable { struct PlatformAsset: Hashable {
var id: String var id: String
@@ -458,6 +464,52 @@ struct CloudIdResult: Hashable {
} }
} }
/// Generated class from Pigeon that represents data sent in messages.
struct BaseResource: Hashable {
var path: String
var sha1: String
var sizeBytes: Int64
var mimeType: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
let path = pigeonVar_list[0] as! String
let sha1 = pigeonVar_list[1] as! String
let sizeBytes = pigeonVar_list[2] as! Int64
let mimeType = pigeonVar_list[3] as! String
return BaseResource(
path: path,
sha1: sha1,
sizeBytes: sizeBytes,
mimeType: mimeType
)
}
func toList() -> [Any?] {
return [
path,
sha1,
sizeBytes,
mimeType,
]
}
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
if Swift.type(of: lhs) != Swift.type(of: rhs) {
return false
}
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1) && deepEqualsMessages(lhs.sizeBytes, rhs.sizeBytes) && deepEqualsMessages(lhs.mimeType, rhs.mimeType)
}
func hash(into hasher: inout Hasher) {
hasher.combine("BaseResource")
deepHashMessages(value: path, hasher: &hasher)
deepHashMessages(value: sha1, hasher: &hasher)
deepHashMessages(value: sizeBytes, hasher: &hasher)
deepHashMessages(value: mimeType, 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 {
@@ -468,15 +520,23 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
} }
return nil return nil
case 130: case 130:
return PlatformAsset.fromList(self.readValue() as! [Any?]) let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return EditState(rawValue: enumResultAsInt)
}
return nil
case 131: case 131:
return PlatformAlbum.fromList(self.readValue() as! [Any?]) return PlatformAsset.fromList(self.readValue() as! [Any?])
case 132: case 132:
return SyncDelta.fromList(self.readValue() as! [Any?]) return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 133: case 133:
return HashResult.fromList(self.readValue() as! [Any?]) return SyncDelta.fromList(self.readValue() as! [Any?])
case 134: case 134:
return HashResult.fromList(self.readValue() as! [Any?])
case 135:
return CloudIdResult.fromList(self.readValue() as! [Any?]) return CloudIdResult.fromList(self.readValue() as! [Any?])
case 136:
return BaseResource.fromList(self.readValue() as! [Any?])
default: default:
return super.readValue(ofType: type) return super.readValue(ofType: type)
} }
@@ -488,21 +548,27 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
if let value = value as? PlatformAssetPlaybackStyle { if let value = value as? PlatformAssetPlaybackStyle {
super.writeByte(129) super.writeByte(129)
super.writeValue(value.rawValue) super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset { } else if let value = value as? EditState {
super.writeByte(130) super.writeByte(130)
super.writeValue(value.toList()) super.writeValue(value.rawValue)
} else if let value = value as? PlatformAlbum { } else if let value = value as? PlatformAsset {
super.writeByte(131) super.writeByte(131)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? SyncDelta { } else if let value = value as? PlatformAlbum {
super.writeByte(132) super.writeByte(132)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? HashResult { } else if let value = value as? SyncDelta {
super.writeByte(133) super.writeByte(133)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? CloudIdResult { } else if let value = value as? HashResult {
super.writeByte(134) super.writeByte(134)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(135)
super.writeValue(value.toList())
} else if let value = value as? BaseResource {
super.writeByte(136)
super.writeValue(value.toList())
} else { } else {
super.writeValue(value) super.writeValue(value)
} }
@@ -538,6 +604,8 @@ protocol NativeSyncApi {
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -738,5 +806,45 @@ class NativeSyncApiSetup {
} else { } else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil) getCloudIdForAssetIdsChannel.setMessageHandler(nil)
} }
let getBaseResourceChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getBaseResourceChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getBaseResourceChannel.setMessageHandler(nil)
}
let getEditStateChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getEditStateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getEditStateChannel.setMessageHandler(nil)
}
} }
} }
+167
View File
@@ -1,5 +1,6 @@
import Photos import Photos
import CryptoKit import CryptoKit
import UniformTypeIdentifiers
struct AssetWrapper: Hashable, Equatable { struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset let asset: PlatformAsset
@@ -415,4 +416,170 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
} }
return mappings; return mappings;
} }
func getBaseResource(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<BaseResource?, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
let resources = PHAssetResource.assetResources(for: asset)
let state = await Self.classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
guard state == .edited, let original = resources.first(where: { $0.type == .photo }) else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
do {
let result = try await self.streamBaseResource(
resource: original,
localId: asset.localIdentifier,
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(result))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
// Returns whether the asset carries a live Photos edit without reading the photo
// itself, only the small adjustment metadata. The revert probe relies on this to
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
// mistakes an unreadable edit for a revert.
func getEditState(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<EditState, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
// Not in the library, so don't answer "not edited" (the caller acts on that).
return self.completeWhenActive(for: completion, with: .success(.unknown))
}
let state = await Self.classifyEdit(
resources: PHAssetResource.assetResources(for: asset),
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(state))
}
}
// adjustmentRenderTypes for a photo with no real edit: a plain capture, a
// Photographic Style, or a reverted edit. A real edit changes this value.
private static let kNoEditRenderTypes = 27648
// Works out the edit state from Adjustments.plist only (never reads the photo). A
// real Photos edit is authored by com.apple.mobileslideshow (or a 3rd-party editor)
// and bumps the render types off the baseline. A plain capture (incl. a Photographic
// Style) is authored by com.apple.camera, and a revert keeps the editor id but resets
// the render types to baseline, so we need both to call it an edit. Cleanup and
// object-removal stay camera-authored but write AdjustmentsSecondary.data, which we
// count as edited. unknown = couldn't read the plist (offloaded, no network).
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
return .edited
}
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
return .notEdited
}
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
else {
return .unknown
}
let editor = plist["adjustmentEditorBundleID"] as? String
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
let isUserEdit = editor != nil && editor != "com.apple.camera" && renderTypes != kNoEditRenderTypes
return isUserEdit ? .edited : .notEdited
}
private func streamBaseResource(
resource: PHAssetResource,
localId: String,
allowNetworkAccess: Bool
) async throws -> BaseResource {
let safeId = localId.replacingOccurrences(of: "/", with: "_")
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("immich_base", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let unique = UUID().uuidString.prefix(8)
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
// Write the resource to disk and hash it chunk by chunk, so a big original (e.g.
// ProRAW) never sits fully in memory on the upload thread.
FileManager.default.createFile(atPath: tempUrl.path, contents: nil)
guard let handle = try? FileHandle(forWritingTo: tempUrl) else {
throw NSError(
domain: "NativeSyncApi",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to open temp file for base resource \(localId)"]
)
}
var hasher = Insecure.SHA1()
var totalBytes: Int64 = 0
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
var writeFailed = false
PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { chunk in
if writeFailed { return }
do {
try handle.write(contentsOf: chunk)
hasher.update(data: chunk)
totalBytes += Int64(chunk.count)
} catch {
writeFailed = true
}
},
completionHandler: { error in continuation.resume(returning: error == nil && !writeFailed) }
)
}
try? handle.close()
guard succeeded else {
try? FileManager.default.removeItem(at: tempUrl)
throw NSError(
domain: "NativeSyncApi",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to read base resource for \(localId)"]
)
}
let sha1 = Data(hasher.finalize()).base64EncodedString()
let mime = UTType(resource.uniformTypeIdentifier)?.preferredMIMEType ?? "application/octet-stream"
return BaseResource(path: tempUrl.path, sha1: sha1, sizeBytes: totalBytes, mimeType: mime)
}
private static func collectResourceData(
_ resource: PHAssetResource,
allowNetworkAccess: Bool
) async -> Data? {
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
var buffer = Data()
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { data in buffer.append(data) },
completionHandler: { error in continuation.resume(returning: error == nil ? buffer : nil) }
)
}
}
} }
+1
View File
@@ -20,6 +20,7 @@ const String kSecuredPinCode = "secured_pin_code";
const String kManualUploadGroup = 'manual_upload_group'; const String kManualUploadGroup = 'manual_upload_group';
const String kBackupGroup = 'backup_group'; const String kBackupGroup = 'backup_group';
const String kBackupLivePhotoGroup = 'backup_live_photo_group'; const String kBackupLivePhotoGroup = 'backup_live_photo_group';
const String kBackupEditPairGroup = 'backup_edit_pair_group';
const String kDownloadGroupImage = 'group_image'; const String kDownloadGroupImage = 'group_image';
const String kDownloadGroupVideo = 'group_video'; const String kDownloadGroupVideo = 'group_video';
const String kDownloadGroupLivePhoto = 'group_livephoto'; const String kDownloadGroupLivePhoto = 'group_livephoto';
@@ -12,6 +12,13 @@ class LocalAsset extends BaseAsset {
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
// Remote id of this asset's previous upload; used to stack a new edit under it.
final String? priorRemoteId;
// Local checksum at the last sync action; lets backup skip an already-handled
// local whose current render hashes fresh (the iOS revert case).
final String? syncedChecksum;
const LocalAsset({ const LocalAsset({
required this.id, required this.id,
String? remoteId, String? remoteId,
@@ -32,6 +39,8 @@ class LocalAsset extends BaseAsset {
this.latitude, this.latitude,
this.longitude, this.longitude,
required super.isEdited, required super.isEdited,
this.priorRemoteId,
this.syncedChecksum,
}) : remoteAssetId = remoteId; }) : remoteAssetId = remoteId;
@override @override
@@ -120,6 +129,8 @@ class LocalAsset extends BaseAsset {
double? latitude, double? latitude,
double? longitude, double? longitude,
bool? isEdited, bool? isEdited,
String? priorRemoteId,
String? syncedChecksum,
}) { }) {
return LocalAsset( return LocalAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -140,6 +151,8 @@ class LocalAsset extends BaseAsset {
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited, isEdited: isEdited ?? this.isEdited,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
); );
} }
} }
@@ -0,0 +1,87 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
/// Handles an edit that was reverted in Photos. The local was uploaded as an edit
/// before but isn't edited now, so flip the stack primary back to the original (via
/// prior_remote_id) and mark it handled so we don't re-upload the reverted render.
/// Nothing is trashed; all the edits stay in the stack.
class EditRevertService {
final NativeSyncApi _nativeSyncApi;
final DriftStackRepository _stackRepository;
final DriftLocalAssetRepository _localAssetRepository;
final AssetApiRepository _assetApiRepository;
final _log = Logger('EditRevertService');
EditRevertService({
required NativeSyncApi nativeSyncApi,
required DriftStackRepository stackRepository,
required DriftLocalAssetRepository localAssetRepository,
required AssetApiRepository assetApiRepository,
}) : _nativeSyncApi = nativeSyncApi,
_stackRepository = stackRepository,
_localAssetRepository = localAssetRepository,
_assetApiRepository = assetApiRepository;
/// Returns true if the asset was a revert and was handled (caller skips the
/// upload); false to fall through to the normal upload path.
Future<bool> tryHandleRevert(LocalAsset asset) async {
if (asset.priorRemoteId == null) {
return false;
}
// Only "not edited" is a revert. `edited` is a fresh edit, so let the pair flow
// take it. `unknown` means we couldn't read the adjustment (offloaded to iCloud,
// network off); bail there too instead of mistaking an unreadable edit for a
// revert and flipping the stack. Network off keeps this a cheap offline read.
try {
final editState = await _nativeSyncApi.getEditState(asset.id, allowNetworkAccess: false);
if (editState != EditState.notEdited) {
return false;
}
} catch (error, stack) {
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
return false;
}
// It's a revert. Styled photos hit this path because iOS re-encodes the revert to
// fresh bytes, so it looks like a new backup candidate and reaches upload.
// Non-styled reverts hash back to the base instead, aren't candidates, and get
// flipped at hash time in HashService._reconcileReverts. Fresh bytes match nothing
// remote, so flip by structure: prior_remote_id is the current primary (the latest
// edit), flip it back to the base.
final String stackId;
final String baseId;
try {
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
if (foundStack == null) {
return false;
}
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
if (base == null) {
return false;
}
stackId = foundStack;
baseId = base;
} catch (error, stack) {
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
return false;
}
try {
await _assetApiRepository.setStackPrimary(stackId, baseId);
await _stackRepository.setPrimary(stackId, baseId);
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum ?? '');
} catch (error, stack) {
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
return false;
}
return true;
}
}
+62 -6
View File
@@ -5,8 +5,10 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.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/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.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';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
const String _kHashCancelledCode = "HASH_CANCELLED"; const String _kHashCancelledCode = "HASH_CANCELLED";
@@ -17,6 +19,8 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final DriftStackRepository _stackRepository;
final AssetApiRepository _assetApiRepository;
final bool Function()? _cancelChecker; final bool Function()? _cancelChecker;
final _log = Logger('HashService'); final _log = Logger('HashService');
@@ -25,6 +29,8 @@ class HashService {
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
required DriftStackRepository stackRepository,
required AssetApiRepository assetApiRepository,
bool Function()? cancelChecker, bool Function()? cancelChecker,
int? batchSize, int? batchSize,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
@@ -32,6 +38,8 @@ class HashService {
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker, _cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi, _nativeSyncApi = nativeSyncApi,
_stackRepository = stackRepository,
_assetApiRepository = assetApiRepository,
_batchSize = batchSize ?? kBatchHashFileLimit; _batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false; bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -45,6 +53,7 @@ class HashService {
// Sorted by backupSelection followed by isCloud // Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums(); final localAlbums = await _localAlbumRepository.getBackupAlbums();
final hashedIds = <String>{};
for (final album in localAlbums) { for (final album in localAlbums) {
if (isCancelled) { if (isCancelled) {
@@ -54,7 +63,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id); final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) { if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash); await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
} }
} }
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) { if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
@@ -62,9 +71,18 @@ class HashService {
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds); final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) { if (trashedToHash.isNotEmpty) {
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now()); final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true); await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
} }
} }
// Revert reconcile for non-styled photos: the reverted edit hashes back to the
// original's exact bytes, which are already the stack base, so it's not a backup
// candidate and never reaches upload. Flip the primary here. Styled photos
// re-encode to fresh bytes and get flipped on the upload path instead
// (EditRevertService.tryHandleRevert).
if (CurrentPlatform.isIOS && hashedIds.isNotEmpty && !isCancelled) {
await _reconcileReverts(hashedIds);
}
} on PlatformException catch (e) { } on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) { if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform"); _log.warning("Hashing cancelled by platform");
@@ -81,7 +99,12 @@ class HashService {
/// 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.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async { Future<void> _hashAssets(
LocalAlbum album,
List<LocalAsset> assetsToHash, {
bool isTrashed = false,
required Set<String> hashedIds,
}) async {
final toHash = <String, LocalAsset>{}; final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) { for (final asset in assetsToHash) {
@@ -92,16 +115,21 @@ class HashService {
toHash[asset.id] = asset; toHash[asset.id] = asset;
if (toHash.length == _batchSize) { if (toHash.length == _batchSize) {
await _processBatch(album, toHash, isTrashed); await _processBatch(album, toHash, isTrashed, hashedIds);
toHash.clear(); toHash.clear();
} }
} }
await _processBatch(album, toHash, isTrashed); await _processBatch(album, toHash, isTrashed, hashedIds);
} }
/// Processes a batch of assets. /// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async { Future<void> _processBatch(
LocalAlbum album,
Map<String, LocalAsset> toHash,
bool isTrashed,
Set<String> hashedIds,
) async {
if (toHash.isEmpty) { if (toHash.isEmpty) {
return; return;
} }
@@ -141,5 +169,33 @@ class HashService {
} else { } else {
await _localAssetRepository.updateHashes(hashed); await _localAssetRepository.updateHashes(hashed);
} }
hashedIds.addAll(hashed.keys);
}
Future<void> _reconcileReverts(Set<String> localIds) async {
final List<StackReconcileTarget> targets;
try {
targets = await _stackRepository.findRevertReconcileTargets(localIds);
} catch (error, stack) {
_log.warning("findRevertReconcileTargets failed", error, stack);
return;
}
for (final target in targets) {
try {
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
// Roll priorRemoteId forward to the matched member (now the primary) so a
// later edit stacks onto THAT (the current render), not the old edit.
await _localAssetRepository.markSynced(
target.localAssetId,
priorRemoteId: target.newPrimaryId,
syncedChecksum: target.localAssetChecksum,
);
} catch (error, stack) {
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
continue;
}
}
} }
} }
@@ -186,6 +186,22 @@ class BackgroundSyncManager {
}); });
} }
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
/// joins an in-flight sync whose snapshot can pre-date a just-received change
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
/// first, then run a fresh one.
Future<void> runFreshRemoteSync() async {
final inflight = _syncTask;
if (inflight != null) {
try {
await inflight.future;
} catch (_) {
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
}
}
await syncRemote();
}
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) { Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
if (_syncWebsocketTask != null) { if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future; return _syncWebsocketTask!.future;
@@ -27,6 +27,14 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))(); IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
// remote id of the previous upload (iOS edit-pair stacking)
TextColumn get priorRemoteId => text().nullable()();
// local checksum at the last sync action. Lets the backup query skip a local
// whose current hash matches nothing remote but is still "handled" — the iOS
// revert case, where the reverted render hashes fresh but is already reconciled.
TextColumn get syncedChecksum => text().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -51,5 +59,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
longitude: longitude, longitude: longitude,
cloudId: iCloudId, cloudId: iCloudId,
isEdited: false, isEdited: false,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
); );
} }
@@ -26,6 +26,8 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<double?> latitude, i0.Value<double?> latitude,
i0.Value<double?> longitude, i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle, i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
}); });
typedef $$LocalAssetEntityTableUpdateCompanionBuilder = typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({ i1.LocalAssetEntityCompanion Function({
@@ -45,6 +47,8 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<double?> latitude, i0.Value<double?> latitude,
i0.Value<double?> longitude, i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle, i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
}); });
class $$LocalAssetEntityTableFilterComposer class $$LocalAssetEntityTableFilterComposer
@@ -141,6 +145,16 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.playbackStyle, column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column), builder: (column) => i0.ColumnWithTypeConverterFilters(column),
); );
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnFilters(column),
);
} }
class $$LocalAssetEntityTableOrderingComposer class $$LocalAssetEntityTableOrderingComposer
@@ -231,6 +245,16 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.playbackStyle, column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnOrderings(column),
);
} }
class $$LocalAssetEntityTableAnnotationComposer class $$LocalAssetEntityTableAnnotationComposer
@@ -300,6 +324,16 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.playbackStyle, column: $table.playbackStyle,
builder: (column) => column, builder: (column) => column,
); );
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => column,
);
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => column,
);
} }
class $$LocalAssetEntityTableTableManager class $$LocalAssetEntityTableTableManager
@@ -359,6 +393,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(), i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle = i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(), const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion( }) => i1.LocalAssetEntityCompanion(
name: name, name: name,
type: type, type: type,
@@ -376,6 +412,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
playbackStyle: playbackStyle, playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
), ),
createCompanionCallback: createCompanionCallback:
({ ({
@@ -396,6 +434,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(), i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle = i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(), const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert( }) => i1.LocalAssetEntityCompanion.insert(
name: name, name: name,
type: type, type: type,
@@ -413,6 +453,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
playbackStyle: playbackStyle, playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -637,6 +679,28 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
).withConverter<i2.AssetPlaybackStyle>( ).withConverter<i2.AssetPlaybackStyle>(
i1.$LocalAssetEntityTable.$converterplaybackStyle, i1.$LocalAssetEntityTable.$converterplaybackStyle,
); );
static const i0.VerificationMeta _priorRemoteIdMeta =
const i0.VerificationMeta('priorRemoteId');
@override
late final i0.GeneratedColumn<String> priorRemoteId =
i0.GeneratedColumn<String>(
'prior_remote_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _syncedChecksumMeta =
const i0.VerificationMeta('syncedChecksum');
@override
late final i0.GeneratedColumn<String> syncedChecksum =
i0.GeneratedColumn<String>(
'synced_checksum',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
@override @override
List<i0.GeneratedColumn> get $columns => [ List<i0.GeneratedColumn> get $columns => [
name, name,
@@ -655,6 +719,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
latitude, latitude,
longitude, longitude,
playbackStyle, playbackStyle,
priorRemoteId,
syncedChecksum,
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -759,6 +825,24 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta), longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
); );
} }
if (data.containsKey('prior_remote_id')) {
context.handle(
_priorRemoteIdMeta,
priorRemoteId.isAcceptableOrUnknown(
data['prior_remote_id']!,
_priorRemoteIdMeta,
),
);
}
if (data.containsKey('synced_checksum')) {
context.handle(
_syncedChecksumMeta,
syncedChecksum.isAcceptableOrUnknown(
data['synced_checksum']!,
_syncedChecksumMeta,
),
);
}
return context; return context;
} }
@@ -839,6 +923,14 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
data['${effectivePrefix}playback_style'], data['${effectivePrefix}playback_style'],
)!, )!,
), ),
priorRemoteId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}prior_remote_id'],
),
syncedChecksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}synced_checksum'],
),
); );
} }
@@ -877,6 +969,8 @@ class LocalAssetEntityData extends i0.DataClass
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
final i2.AssetPlaybackStyle playbackStyle; final i2.AssetPlaybackStyle playbackStyle;
final String? priorRemoteId;
final String? syncedChecksum;
const LocalAssetEntityData({ const LocalAssetEntityData({
required this.name, required this.name,
required this.type, required this.type,
@@ -894,6 +988,8 @@ class LocalAssetEntityData extends i0.DataClass
this.latitude, this.latitude,
this.longitude, this.longitude,
required this.playbackStyle, required this.playbackStyle,
this.priorRemoteId,
this.syncedChecksum,
}); });
@override @override
Map<String, i0.Expression> toColumns(bool nullToAbsent) { Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -938,6 +1034,12 @@ class LocalAssetEntityData extends i0.DataClass
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle), i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
); );
} }
if (!nullToAbsent || priorRemoteId != null) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
}
if (!nullToAbsent || syncedChecksum != null) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
}
return map; return map;
} }
@@ -967,6 +1069,8 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson( playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
serializer.fromJson<int>(json['playbackStyle']), serializer.fromJson<int>(json['playbackStyle']),
), ),
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
); );
} }
@override @override
@@ -993,6 +1097,8 @@ class LocalAssetEntityData extends i0.DataClass
'playbackStyle': serializer.toJson<int>( 'playbackStyle': serializer.toJson<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle), i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
), ),
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
}; };
} }
@@ -1013,6 +1119,8 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<double?> latitude = const i0.Value.absent(), i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(), i0.Value<double?> longitude = const i0.Value.absent(),
i2.AssetPlaybackStyle? playbackStyle, i2.AssetPlaybackStyle? playbackStyle,
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityData( }) => i1.LocalAssetEntityData(
name: name ?? this.name, name: name ?? this.name,
type: type ?? this.type, type: type ?? this.type,
@@ -1032,6 +1140,12 @@ class LocalAssetEntityData extends i0.DataClass
latitude: latitude.present ? latitude.value : this.latitude, latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude, longitude: longitude.present ? longitude.value : this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle, playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId.present
? priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: syncedChecksum.present
? syncedChecksum.value
: this.syncedChecksum,
); );
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) { LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData( return LocalAssetEntityData(
@@ -1061,6 +1175,12 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: data.playbackStyle.present playbackStyle: data.playbackStyle.present
? data.playbackStyle.value ? data.playbackStyle.value
: this.playbackStyle, : this.playbackStyle,
priorRemoteId: data.priorRemoteId.present
? data.priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: data.syncedChecksum.present
? data.syncedChecksum.value
: this.syncedChecksum,
); );
} }
@@ -1082,7 +1202,9 @@ class LocalAssetEntityData extends i0.DataClass
..write('adjustmentTime: $adjustmentTime, ') ..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ') ..write('latitude: $latitude, ')
..write('longitude: $longitude, ') ..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle') ..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -1105,6 +1227,8 @@ class LocalAssetEntityData extends i0.DataClass
latitude, latitude,
longitude, longitude,
playbackStyle, playbackStyle,
priorRemoteId,
syncedChecksum,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -1125,7 +1249,9 @@ class LocalAssetEntityData extends i0.DataClass
other.adjustmentTime == this.adjustmentTime && other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude && other.latitude == this.latitude &&
other.longitude == this.longitude && other.longitude == this.longitude &&
other.playbackStyle == this.playbackStyle); other.playbackStyle == this.playbackStyle &&
other.priorRemoteId == this.priorRemoteId &&
other.syncedChecksum == this.syncedChecksum);
} }
class LocalAssetEntityCompanion class LocalAssetEntityCompanion
@@ -1146,6 +1272,8 @@ class LocalAssetEntityCompanion
final i0.Value<double?> latitude; final i0.Value<double?> latitude;
final i0.Value<double?> longitude; final i0.Value<double?> longitude;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle; final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
final i0.Value<String?> priorRemoteId;
final i0.Value<String?> syncedChecksum;
const LocalAssetEntityCompanion({ const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(), this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(), this.type = const i0.Value.absent(),
@@ -1163,6 +1291,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(), this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(), this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(), this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
}); });
LocalAssetEntityCompanion.insert({ LocalAssetEntityCompanion.insert({
required String name, required String name,
@@ -1181,6 +1311,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(), this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(), this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(), this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
}) : name = i0.Value(name), }) : name = i0.Value(name),
type = i0.Value(type), type = i0.Value(type),
id = i0.Value(id); id = i0.Value(id);
@@ -1201,6 +1333,8 @@ class LocalAssetEntityCompanion
i0.Expression<double>? latitude, i0.Expression<double>? latitude,
i0.Expression<double>? longitude, i0.Expression<double>? longitude,
i0.Expression<int>? playbackStyle, i0.Expression<int>? playbackStyle,
i0.Expression<String>? priorRemoteId,
i0.Expression<String>? syncedChecksum,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
if (name != null) 'name': name, if (name != null) 'name': name,
@@ -1219,6 +1353,8 @@ class LocalAssetEntityCompanion
if (latitude != null) 'latitude': latitude, if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude, if (longitude != null) 'longitude': longitude,
if (playbackStyle != null) 'playback_style': playbackStyle, if (playbackStyle != null) 'playback_style': playbackStyle,
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
}); });
} }
@@ -1239,6 +1375,8 @@ class LocalAssetEntityCompanion
i0.Value<double?>? latitude, i0.Value<double?>? latitude,
i0.Value<double?>? longitude, i0.Value<double?>? longitude,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle, i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
i0.Value<String?>? priorRemoteId,
i0.Value<String?>? syncedChecksum,
}) { }) {
return i1.LocalAssetEntityCompanion( return i1.LocalAssetEntityCompanion(
name: name ?? this.name, name: name ?? this.name,
@@ -1257,6 +1395,8 @@ class LocalAssetEntityCompanion
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle, playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
); );
} }
@@ -1317,6 +1457,12 @@ class LocalAssetEntityCompanion
), ),
); );
} }
if (priorRemoteId.present) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
}
if (syncedChecksum.present) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
}
return map; return map;
} }
@@ -1338,7 +1484,9 @@ class LocalAssetEntityCompanion
..write('adjustmentTime: $adjustmentTime, ') ..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ') ..write('latitude: $latitude, ')
..write('longitude: $longitude, ') ..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle') ..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -83,6 +83,13 @@ AND NOT EXISTS (
INNER JOIN local_album_entity la on laa.album_id = la.id INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
) )
-- iOS edit-in-progress / revert: if this local was already uploaded (its
-- prior_remote_id resolves to a live remote), hide the local tile so the remote
-- (the edit, or the flipped-back original) is the single source of truth. Kills
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $limit; LIMIT $limit;
@@ -136,6 +143,10 @@ FROM
INNER JOIN local_album_entity la on laa.album_id = la.id INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
) )
-- iOS edit-in-progress / revert: hide a local already represented by a live remote.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
)
) )
GROUP BY bucket_date GROUP BY bucket_date
ORDER BY bucket_date DESC; ORDER BY bucket_date DESC;
+2 -2
View File
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
); );
$arrayStartIndex += generatedlimit.amountOfVariables; $arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect( return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}', 'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds)) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [ variables: [
for (var $ in userIds) i0.Variable<String>($), for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables, ...generatedlimit.introducedVariables,
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length); final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length; $arrayStartIndex += userIds.length;
return customSelect( return customSelect(
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC', 'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds))) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [ variables: [
i0.Variable<int>(groupBy), i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($), for (var $ in userIds) i0.Variable<String>($),
@@ -58,7 +58,8 @@ class DriftBackupRepository extends DriftDatabaseRepository {
INNER JOIN main.local_album_entity la on laa.album_id = la.id INNER JOIN main.local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id WHERE laa.asset_id = lae.id
AND la.backup_selection = ?3 AND la.backup_selection = ?3
); )
AND (lae.synced_checksum IS NULL OR lae.synced_checksum != lae.checksum);
'''; ''';
final row = await _db final row = await _db
@@ -104,6 +105,10 @@ class DriftBackupRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId), _db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
), ),
) & ) &
// iOS revert: a reverted local hashes fresh (matches nothing remote),
// but if it was already reconciled (syncedChecksum == current checksum)
// it's handled — don't re-queue it as a fresh upload.
(lae.syncedChecksum.isNull() | lae.syncedChecksum.equalsExp(lae.checksum).not()) &
lae.id.isNotInQuery(_getExcludedSubquery()), lae.id.isNotInQuery(_getExcludedSubquery()),
) )
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]); ..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
@@ -98,7 +98,7 @@ class Drift extends $Drift {
} }
@override @override
int get schemaVersion => 26; int get schemaVersion => 28;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -276,6 +276,12 @@ class Drift extends $Drift {
from25To26: (m, v26) async { from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt); await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
}, },
from26To27: (m, v27) async {
await m.addColumn(v27.localAssetEntity, v27.localAssetEntity.priorRemoteId);
},
from27To28: (m, v28) async {
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.syncedChecksum);
},
), ),
); );
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
}); });
} }
Future<void> markSynced(String localId, {required String priorRemoteId, required String syncedChecksum}) {
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
);
}
Future<void> delete(List<String> ids) { Future<void> delete(List<String> ids) {
if (ids.isEmpty) { if (ids.isEmpty) {
return Future.value(); return Future.value();
@@ -1,8 +1,24 @@
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/stack.model.dart'; import 'package:immich_mobile/domain/models/stack.model.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/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class StackReconcileTarget {
final String stackId;
final String newPrimaryId;
final String localAssetId;
final String localAssetChecksum;
const StackReconcileTarget({
required this.stackId,
required this.newPrimaryId,
required this.localAssetId,
required this.localAssetChecksum,
});
}
class DriftStackRepository extends DriftDatabaseRepository { class DriftStackRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
const DriftStackRepository(this._db) : super(_db); const DriftStackRepository(this._db) : super(_db);
@@ -14,6 +30,95 @@ class DriftStackRepository extends DriftDatabaseRepository {
return stack.toDto(); return stack.toDto();
}).get(); }).get();
} }
// Per local id, find a stack member whose checksum matches the local's current
// checksum but isn't the stack primary — the iOS revert case where the local
// hashed back to the base while the primary still points at the edit.
Future<List<StackReconcileTarget>> findRevertReconcileTargets(Iterable<String> localAssetIds) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return const [];
}
final targets = <StackReconcileTarget>[];
for (final slice in ids.slices(kDriftMaxChunk)) {
final placeholders = List.filled(slice.length, '?').join(',');
final rows = await _db
.customSelect(
'''
SELECT
s.id AS stack_id,
member.id AS new_primary,
local.id AS local_id,
local.checksum AS local_checksum
FROM local_asset_entity local
INNER JOIN remote_asset_entity prior ON prior.id = local.prior_remote_id
INNER JOIN stack_entity s ON s.id = prior.stack_id
INNER JOIN remote_asset_entity member
ON member.stack_id = s.id
AND member.checksum = local.checksum
AND member.deleted_at IS NULL
WHERE local.id IN ($placeholders)
AND s.primary_asset_id != member.id
''',
variables: slice.map((id) => Variable<String>(id)).toList(),
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
)
.get();
for (final row in rows) {
targets.add(
StackReconcileTarget(
stackId: row.read<String>('stack_id'),
newPrimaryId: row.read<String>('new_primary'),
localAssetId: row.read<String>('local_id'),
localAssetChecksum: row.read<String>('local_checksum'),
),
);
}
}
return targets;
}
// The stack a remote asset belongs to, if any. Used by the revert path to find
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
Future<String?> findStackIdByRemoteId(String remoteId) async {
final row = await _db
.customSelect(
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
variables: [Variable<String>(remoteId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
return row?.read<String?>('stack_id');
}
// The stack's original base member to flip back to on revert: the earliest-
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
// before its edits, so oldest uploaded_at = the original.
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
final row = await _db
.customSelect(
'''
SELECT id FROM remote_asset_entity
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
LIMIT 1
''',
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
return row?.read<String?>('id');
}
// Optimistic local primary flip so the timeline updates immediately; the
// server's stack-update websocket rewrites it shortly after.
Future<void> setPrimary(String stackId, String primaryAssetId) {
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
);
}
} }
extension on StackEntityData { extension on StackEntityData {
+110 -10
View File
@@ -88,6 +88,8 @@ int _deepHash(Object? value) {
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum EditState { notEdited, edited, unknown }
class PlatformAsset { class PlatformAsset {
PlatformAsset({ PlatformAsset({
required this.id, required this.id,
@@ -395,6 +397,55 @@ class CloudIdResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
} }
class BaseResource {
BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
String path;
String sha1;
int sizeBytes;
String mimeType;
List<Object?> _toList() {
return <Object?>[path, sha1, sizeBytes, mimeType];
}
Object encode() {
return _toList();
}
static BaseResource decode(Object result) {
result as List<Object?>;
return BaseResource(
path: result[0]! as String,
sha1: result[1]! as String,
sizeBytes: result[2]! as int,
mimeType: result[3]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! BaseResource || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(sha1, other.sha1) &&
_deepEquals(sizeBytes, other.sizeBytes) &&
_deepEquals(mimeType, other.mimeType);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -405,21 +456,27 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is PlatformAssetPlaybackStyle) { } else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.index); writeValue(buffer, value.index);
} else if (value is PlatformAsset) { } else if (value is EditState) {
buffer.putUint8(130); buffer.putUint8(130);
writeValue(buffer, value.encode()); writeValue(buffer, value.index);
} else if (value is PlatformAlbum) { } else if (value is PlatformAsset) {
buffer.putUint8(131); buffer.putUint8(131);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is SyncDelta) { } else if (value is PlatformAlbum) {
buffer.putUint8(132); buffer.putUint8(132);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is HashResult) { } else if (value is SyncDelta) {
buffer.putUint8(133); buffer.putUint8(133);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is CloudIdResult) { } else if (value is HashResult) {
buffer.putUint8(134); buffer.putUint8(134);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
buffer.putUint8(135);
writeValue(buffer, value.encode());
} else if (value is BaseResource) {
buffer.putUint8(136);
writeValue(buffer, value.encode());
} else { } else {
super.writeValue(buffer, value); super.writeValue(buffer, value);
} }
@@ -432,15 +489,20 @@ class _PigeonCodec extends StandardMessageCodec {
final value = readValue(buffer) as int?; final value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value]; return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130: case 130:
return PlatformAsset.decode(readValue(buffer)!); final value = readValue(buffer) as int?;
return value == null ? null : EditState.values[value];
case 131: case 131:
return PlatformAlbum.decode(readValue(buffer)!); return PlatformAsset.decode(readValue(buffer)!);
case 132: case 132:
return SyncDelta.decode(readValue(buffer)!); return PlatformAlbum.decode(readValue(buffer)!);
case 133: case 133:
return HashResult.decode(readValue(buffer)!); return SyncDelta.decode(readValue(buffer)!);
case 134: case 134:
return HashResult.decode(readValue(buffer)!);
case 135:
return CloudIdResult.decode(readValue(buffer)!); return CloudIdResult.decode(readValue(buffer)!);
case 136:
return BaseResource.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
} }
@@ -672,4 +734,42 @@ class NativeSyncApi {
); );
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>(); return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
} }
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as BaseResource?;
}
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as EditState;
}
} }
@@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart'; import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart';
@@ -11,7 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider))); final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -45,11 +48,22 @@ final localSyncServiceProvider = Provider(
), ),
); );
final editRevertServiceProvider = Provider(
(ref) => EditRevertService(
nativeSyncApi: ref.watch(nativeSyncApiProvider),
stackRepository: ref.watch(driftStackProvider),
localAssetRepository: ref.watch(localAssetRepository),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
),
);
final hashServiceProvider = Provider( final hashServiceProvider = Provider(
(ref) => HashService( (ref) => HashService(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
stackRepository: ref.watch(driftStackProvider),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
), ),
); );
@@ -105,6 +105,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates); socket.on('on_new_release', _handleReleaseUpdates);
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
} catch (e) { } catch (e) {
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
} }
@@ -188,6 +189,13 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data)); unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
} }
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
// the stacked primary instead of briefly hiding the asset.
void _handleAssetStackUpdate(dynamic _) {
unawaited(_ref.read(backgroundSyncProvider).runFreshRemoteSync());
}
void _processBatchedAssetUploadReadyV1() { void _processBatchedAssetUploadReadyV1() {
if (_batchedAssetUploadReady.isEmpty) { if (_batchedAssetUploadReady.isEmpty) {
return; return;
@@ -67,6 +67,10 @@ class AssetApiRepository extends ApiRepository {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids)); return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
} }
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
}
Future<Response> downloadAsset(String id, {required bool edited}) { Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited); return _api.downloadAssetWithHttpInfo(id, edited: edited);
} }
@@ -30,6 +30,11 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update), taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update),
); );
FileDownloader().registerCallbacks(
group: kBackupEditPairGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks( FileDownloader().registerCallbacks(
group: kManualUploadGroup, group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update), taskStatusCallback: (update) => onUploadStatus?.call(update),
@@ -9,17 +9,22 @@ 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/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/domain/services/edit_revert.service.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/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/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.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/platform.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/infrastructure/sync.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';
import 'package:immich_mobile/services/edit_pair.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -31,6 +36,8 @@ final backgroundUploadServiceProvider = Provider((ref) {
ref.watch(localAssetRepository), ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(editRevertServiceProvider),
); );
ref.onDispose(service.dispose); ref.onDispose(service.dispose);
@@ -43,13 +50,35 @@ class UploadTaskMetadata {
final bool isLivePhotos; final bool isLivePhotos;
final String livePhotoVideoId; final String livePhotoVideoId;
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId}); // Marks the base upload of an edit pair. On completion the chained edit
// upload is enqueued with stackParentId = this base's remote id.
final bool isEditPair;
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) { // Path of the native temp file backing this task (the edit base), so it can
// be cleaned up on terminal status.
final String basePath;
const UploadTaskMetadata({
required this.localAssetId,
required this.isLivePhotos,
required this.livePhotoVideoId,
this.isEditPair = false,
this.basePath = '',
});
UploadTaskMetadata copyWith({
String? localAssetId,
bool? isLivePhotos,
String? livePhotoVideoId,
bool? isEditPair,
String? basePath,
}) {
return UploadTaskMetadata( return UploadTaskMetadata(
localAssetId: localAssetId ?? this.localAssetId, localAssetId: localAssetId ?? this.localAssetId,
isLivePhotos: isLivePhotos ?? this.isLivePhotos, isLivePhotos: isLivePhotos ?? this.isLivePhotos,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
isEditPair: isEditPair ?? this.isEditPair,
basePath: basePath ?? this.basePath,
); );
} }
@@ -58,6 +87,8 @@ class UploadTaskMetadata {
'localAssetId': localAssetId, 'localAssetId': localAssetId,
'isLivePhotos': isLivePhotos, 'isLivePhotos': isLivePhotos,
'livePhotoVideoId': livePhotoVideoId, 'livePhotoVideoId': livePhotoVideoId,
'isEditPair': isEditPair,
'basePath': basePath,
}; };
} }
@@ -66,6 +97,8 @@ class UploadTaskMetadata {
localAssetId: map['localAssetId'] as String, localAssetId: map['localAssetId'] as String,
isLivePhotos: map['isLivePhotos'] as bool, isLivePhotos: map['isLivePhotos'] as bool,
livePhotoVideoId: map['livePhotoVideoId'] as String, livePhotoVideoId: map['livePhotoVideoId'] as String,
isEditPair: (map['isEditPair'] as bool?) ?? false,
basePath: (map['basePath'] as String?) ?? '',
); );
} }
@@ -76,7 +109,7 @@ class UploadTaskMetadata {
@override @override
String toString() => String toString() =>
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)'; 'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
@override @override
bool operator ==(covariant UploadTaskMetadata other) { bool operator ==(covariant UploadTaskMetadata other) {
@@ -86,11 +119,18 @@ class UploadTaskMetadata {
return other.localAssetId == localAssetId && return other.localAssetId == localAssetId &&
other.isLivePhotos == isLivePhotos && other.isLivePhotos == isLivePhotos &&
other.livePhotoVideoId == livePhotoVideoId; other.livePhotoVideoId == livePhotoVideoId &&
other.isEditPair == isEditPair &&
other.basePath == basePath;
} }
@override @override
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode; int get hashCode =>
localAssetId.hashCode ^
isLivePhotos.hashCode ^
livePhotoVideoId.hashCode ^
isEditPair.hashCode ^
basePath.hashCode;
} }
/// Service for handling background uploads using iOS URLSession (background_downloader) /// Service for handling background uploads using iOS URLSession (background_downloader)
@@ -104,6 +144,8 @@ class BackgroundUploadService {
this._localAssetRepository, this._localAssetRepository,
this._backupRepository, this._backupRepository,
this._assetMediaRepository, this._assetMediaRepository,
this._nativeSyncApi,
this._editRevertService,
) { ) {
_uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback;
@@ -114,6 +156,8 @@ class BackgroundUploadService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository; final DriftBackupRepository _backupRepository;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final EditRevertService _editRevertService;
final Logger _logger = Logger('BackgroundUploadService'); final Logger _logger = Logger('BackgroundUploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast(); final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
@@ -205,9 +249,20 @@ class BackgroundUploadService {
} }
void _handleTaskStatusUpdate(TaskStatusUpdate update) async { void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
UploadTaskMetadata? metadata;
if (update.task.metaData.isNotEmpty) {
try {
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
} catch (_) {
metadata = null;
}
}
switch (update.status) { switch (update.status) {
case TaskStatus.complete: case TaskStatus.complete:
unawaited(_handleLivePhoto(update)); unawaited(_handleLivePhoto(update, metadata));
unawaited(handleEditPair(update, metadata));
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
try { try {
@@ -220,19 +275,20 @@ class BackgroundUploadService {
break; break;
case TaskStatus.failed:
case TaskStatus.canceled:
case TaskStatus.notFound:
unawaited(_cleanupTempResourceOnFailure(metadata));
break;
default: default:
break; break;
} }
} }
Future<void> _handleLivePhoto(TaskStatusUpdate update) async { Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try { try {
if (update.task.metaData.isEmpty || update.task.metaData == '') { if (metadata == null || !metadata.isLivePhotos) {
return;
}
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
if (!metadata.isLivePhotos) {
return; return;
} }
@@ -258,6 +314,143 @@ class BackgroundUploadService {
} }
} }
/// When an edit-pair base upload finishes, enqueue the edit on top of it
/// (stackParentId = the base's new remote id).
@visibleForTesting
Future<void> handleEditPair(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || !metadata.isEditPair) {
return;
}
if (metadata.basePath.isNotEmpty) {
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
final baseRemoteId = _remoteIdFromResponse(update);
if (baseRemoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
final editTask = await getEditUploadTask(localAsset, baseRemoteId);
if (editTask != null) {
await enqueueTasks([editTask]);
}
} catch (error, stackTrace) {
dPrint(() => "Error handling edit pair task: $error $stackTrace");
}
}
/// Saves the uploaded remote id as the asset's priorRemoteId so a later edit
/// stacks onto it. Skipped for edit-pair base uploads; the chained edit records it.
@visibleForTesting
Future<void> recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || metadata.isEditPair || metadata.localAssetId.isEmpty) {
return;
}
final remoteId = _remoteIdFromResponse(update);
if (remoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
await _localAssetRepository.markSynced(
metadata.localAssetId,
priorRemoteId: remoteId,
syncedChecksum: localAsset?.checksum ?? '',
);
} catch (error, stackTrace) {
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
}
}
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
if (metadata == null || metadata.basePath.isEmpty) {
return;
}
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
/// The new asset's remote id from an upload's response body, or null if the
/// body is missing/malformed.
String? _remoteIdFromResponse(TaskStatusUpdate update) {
final body = update.responseBody;
if (body == null || body.isEmpty) {
return null;
}
try {
return jsonDecode(body)['id'] as String?;
} catch (_) {
return null;
}
}
Future<UploadTask> _buildBaseUploadTask(LocalAsset asset, BaseResource base) async {
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
basePath: base.path,
).toJson();
// The base is the unedited original (no adjustmentTime); the `_base`
// deviceAssetId keeps it distinct from the chained edit task.
return buildUploadTask(
File(base.path),
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
deviceAssetId: '${asset.id}_base',
metadata: metadata,
group: kBackupGroup,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getEditUploadTask(LocalAsset asset, String stackParentId) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
final fields = {'stackParentId': stackParentId};
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '').toJson();
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
fields: fields,
group: kBackupEditPairGroup,
priority: 0,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting @visibleForTesting
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async { Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset); final entity = await _storageRepository.getAssetEntityForAsset(asset);
@@ -266,6 +459,24 @@ class BackgroundUploadService {
return null; return null;
} }
// iOS edit pair: stack a user edit onto its original. resolveEditPair decides
// whether to reuse a prior upload or upload the base first. Live photos skip this.
if (!entity.isLivePhoto && CurrentPlatform.isIOS) {
// A reverted edit flips the stack back to the original and skips the upload.
if (asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
return null;
}
final plan = await resolveEditPair(_nativeSyncApi, asset, log: _logger);
switch (plan) {
case UploadBaseFirst(:final base):
return _buildBaseUploadTask(asset, base);
case AbsorbIntoPrior(:final parentId):
return getEditUploadTask(asset, parentId);
case NoEditPair():
break;
}
}
File? file; File? file;
/// iOS LivePhoto has two files: a photo and a video. /// iOS LivePhoto has two files: a photo and a video.
+57
View File
@@ -0,0 +1,57 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:logging/logging.dart';
/// What to do with an edited iOS photo when backing it up.
sealed class EditPairPlan {
const EditPairPlan();
}
/// Not something we stack: not edited, identical bytes, or couldn't read it.
class NoEditPair extends EditPairPlan {
const NoEditPair();
}
/// Already uploaded before; stack the edit onto that remote id.
class AbsorbIntoPrior extends EditPairPlan {
final String parentId;
const AbsorbIntoPrior(this.parentId);
}
/// Upload the original first; [base] is its temp file.
class UploadBaseFirst extends EditPairPlan {
final BaseResource base;
const UploadBaseFirst(this.base);
}
/// Works out how an edited photo should stack: reuse a prior upload, upload the
/// original first, or do nothing. Shared by the foreground and background upload
/// paths. The caller already checked it's iOS and not a live photo.
Future<EditPairPlan> resolveEditPair(NativeSyncApi nativeSyncApi, LocalAsset asset, {Logger? log}) async {
if (asset.priorRemoteId != null) {
return AbsorbIntoPrior(asset.priorRemoteId!);
}
BaseResource? base;
try {
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
} catch (error, stack) {
log?.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
return const NoEditPair();
}
if (base == null) {
return const NoEditPair();
}
// Identical bytes (e.g. auto-HDR), nothing real to stack. Drop the temp copy.
if (base.sha1 == asset.checksum) {
try {
await File(base.path).delete();
} catch (_) {}
return const NoEditPair();
}
return UploadBaseFirst(base);
}
@@ -6,18 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.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/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_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/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.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/infrastructure/sync.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/edit_pair.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -39,6 +45,9 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider), ref.watch(connectivityApiProvider),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(localAssetRepository),
ref.watch(editRevertServiceProvider),
); );
}); });
@@ -54,6 +63,9 @@ class ForegroundUploadService {
this._backupRepository, this._backupRepository,
this._connectivityApi, this._connectivityApi,
this._assetMediaRepository, this._assetMediaRepository,
this._nativeSyncApi,
this._localAssetRepository,
this._editRevertService,
); );
final UploadRepository _uploadRepository; final UploadRepository _uploadRepository;
@@ -61,6 +73,9 @@ class ForegroundUploadService {
final DriftBackupRepository _backupRepository; final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi; final ConnectivityApi _connectivityApi;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final DriftLocalAssetRepository _localAssetRepository;
final EditRevertService _editRevertService;
final Logger _logger = Logger('ForegroundUploadService'); final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false; bool shouldAbortUpload = false;
@@ -250,6 +265,12 @@ class ForegroundUploadService {
return; return;
} }
// A reverted iOS edit flips the stack back to the original and skips the upload.
if (CurrentPlatform.isIOS && asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
callbacks.onSuccess?.call(asset.localId!, asset.priorRemoteId!);
return;
}
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id); final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) { if (!isAvailableLocally && CurrentPlatform.isIOS) {
@@ -371,6 +392,11 @@ class ForegroundUploadService {
]); ]);
} }
final stackParentId = await _maybeUploadBaseResource(asset, Map.of(fields), cancelToken);
if (stackParentId != null) {
fields['stackParentId'] = stackParentId;
}
final onProgress = callbacks.onProgress; final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile( final result = await _uploadRepository.uploadFile(
file: file, file: file,
@@ -384,6 +410,13 @@ class ForegroundUploadService {
); );
if (result.isSuccess && result.remoteAssetId != null) { if (result.isSuccess && result.remoteAssetId != null) {
unawaited(
_localAssetRepository.markSynced(
asset.localId!,
priorRemoteId: result.remoteAssetId!,
syncedChecksum: asset.checksum ?? '',
),
);
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) { } else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user"); _logger.warning(() => "Backup was cancelled by the user");
@@ -415,6 +448,43 @@ class ForegroundUploadService {
} }
} }
/// For an edited iOS photo, uploads the original camera bytes and returns its
/// remote id to use as the edit's stackParentId. Returns null for non-edits.
Future<String?> _maybeUploadBaseResource(
LocalAsset asset,
Map<String, String> baseFields,
Completer<void>? cancelToken,
) async {
if (!CurrentPlatform.isIOS) {
return null;
}
final plan = await resolveEditPair(_nativeSyncApi, asset, log: _logger);
switch (plan) {
case NoEditPair():
return null;
case AbsorbIntoPrior(:final parentId):
return parentId;
case UploadBaseFirst(:final base):
final baseFile = File(base.path);
try {
final baseName = p.setExtension(asset.name, p.extension(base.path));
final result = await _uploadRepository.uploadFile(
file: baseFile,
originalFileName: baseName,
fields: baseFields,
cancelToken: cancelToken,
logContext: 'baseResource[${asset.localId}]',
);
return result.isSuccess ? result.remoteAssetId : null;
} finally {
try {
await baseFile.delete();
} catch (_) {}
}
}
}
Future<UploadResult> _uploadSingleFile( Future<UploadResult> _uploadSingleFile(
File file, { File file, {
required String deviceAssetId, required String deviceAssetId,
+13 -3
View File
@@ -1252,8 +1252,11 @@ class AssetsApi {
/// * [MultipartFile] sidecarData: /// * [MultipartFile] sidecarData:
/// Sidecar file data /// Sidecar file data
/// ///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/assets'; final apiPath = r'/assets';
@@ -1317,6 +1320,10 @@ class AssetsApi {
mp.fields[r'sidecarData'] = sidecarData.field; mp.fields[r'sidecarData'] = sidecarData.field;
mp.files.add(sidecarData); mp.files.add(sidecarData);
} }
if (stackParentId != null) {
hasFields = true;
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
}
if (visibility != null) { if (visibility != null) {
hasFields = true; hasFields = true;
mp.fields[r'visibility'] = parameterToString(visibility); mp.fields[r'visibility'] = parameterToString(visibility);
@@ -1376,9 +1383,12 @@ class AssetsApi {
/// * [MultipartFile] sidecarData: /// * [MultipartFile] sidecarData:
/// Sidecar file data /// Sidecar file data
/// ///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility: /// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, ); final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+23
View File
@@ -110,6 +110,21 @@ class CloudIdResult {
const CloudIdResult({required this.assetId, this.error, this.cloudId}); const CloudIdResult({required this.assetId, this.error, this.cloudId});
} }
class BaseResource {
final String path;
final String sha1;
final int sizeBytes;
final String mimeType;
const BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
}
// Whether an iOS asset currently carries a user edit, as opposed to a
// capture-time Photographic Style or a reverted edit. `unknown` means the
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
// network wasn't allowed), so callers must not treat it as "not edited".
enum EditState { notEdited, edited, unknown }
@HostApi() @HostApi()
abstract class NativeSyncApi { abstract class NativeSyncApi {
bool shouldFullSync(); bool shouldFullSync();
@@ -144,4 +159,12 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds); List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
} }
+3
View File
@@ -1,3 +1,4 @@
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -11,3 +12,5 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {} class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {} class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockEditRevertService extends Mock implements EditRevertService {}
+8
View File
@@ -30,6 +30,8 @@ import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24; import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25; import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26; import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -87,6 +89,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v25.DatabaseAtV25(db); return v25.DatabaseAtV25(db);
case 26: case 26:
return v26.DatabaseAtV26(db); return v26.DatabaseAtV26(db);
case 27:
return v27.DatabaseAtV27(db);
case 28:
return v28.DatabaseAtV28(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@@ -119,5 +125,7 @@ class GeneratedHelper implements SchemaInstantiationHelper {
24, 24,
25, 25,
26, 26,
27,
28,
]; ];
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.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/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
@@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {} class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
class MockStorageRepository extends Mock implements StorageRepository {} class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {} class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
@@ -1,11 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
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/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/constants/constants.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/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
@@ -13,9 +15,11 @@ 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/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.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 '../mocks/asset_entity.mock.dart'; import '../mocks/asset_entity.mock.dart';
@@ -28,10 +32,14 @@ void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository; late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockDriftBackupRepository mockBackupRepository; late MockDriftBackupRepository mockBackupRepository;
late MockAssetMediaRepository mockAssetMediaRepository; late MockAssetMediaRepository mockAssetMediaRepository;
late MockNativeSyncApi mockNativeSyncApi;
late MockEditRevertService mockEditRevertService;
late Drift db; late Drift db;
setUpAll(() async { setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<UploadTask>[]);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'), const MethodChannel('plugins.flutter.io/path_provider'),
(MethodCall methodCall) async => 'test', (MethodCall methodCall) async => 'test',
@@ -50,6 +58,8 @@ void main() {
mockLocalAssetRepository = MockDriftLocalAssetRepository(); mockLocalAssetRepository = MockDriftLocalAssetRepository();
mockBackupRepository = MockDriftBackupRepository(); mockBackupRepository = MockDriftBackupRepository();
mockAssetMediaRepository = MockAssetMediaRepository(); mockAssetMediaRepository = MockAssetMediaRepository();
mockNativeSyncApi = MockNativeSyncApi();
mockEditRevertService = MockEditRevertService();
sut = BackgroundUploadService( sut = BackgroundUploadService(
mockUploadRepository, mockUploadRepository,
@@ -57,8 +67,18 @@ void main() {
mockLocalAssetRepository, mockLocalAssetRepository,
mockBackupRepository, mockBackupRepository,
mockAssetMediaRepository, mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
); );
// Default: no edit base, so getUploadTask falls through to the normal path.
when(
() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => null);
// Default: not a revert, so getUploadTask proceeds with the normal flow.
when(() => mockEditRevertService.tryHandleRevert(any())).thenAnswer((_) async => false);
mockUploadRepository.onUploadStatus = (_) {}; mockUploadRepository.onUploadStatus = (_) {};
mockUploadRepository.onTaskProgress = (_) {}; mockUploadRepository.onTaskProgress = (_) {};
}); });
@@ -122,6 +142,171 @@ void main() {
}); });
}); });
group('getUploadTask edit pair', () {
test('absorption: stacks the edit under the prior upload via stackParentId', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(priorRemoteId: 'prior-remote-1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupEditPairGroup);
expect(task.fields['stackParentId'], 'prior-remote-1');
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('builds a base upload task for an unsynced edit', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(checksum: 'edited-sha1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'original-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.metaData, contains('"isEditPair":true'));
});
test('falls through to a normal upload when base bytes match the checksum', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(checksum: 'same-sha1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'same-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.fields.containsKey('stackParentId'), isFalse);
});
});
group('edit pair completion', () {
test('handleEditPair: enqueues the edit stacked onto the uploaded base', () async {
final asset = LocalAssetStub.image1;
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
);
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
TaskStatus.complete,
null,
'{"id":"base-remote-1"}',
);
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
when(() => mockUploadRepository.enqueueBackgroundAll(any())).thenAnswer((_) async => [true]);
await sut.handleEditPair(update, metadata);
final enqueued =
verify(() => mockUploadRepository.enqueueBackgroundAll(captureAny())).captured.single as List<UploadTask>;
expect(enqueued.single.fields['stackParentId'], 'base-remote-1');
expect(enqueued.single.group, kBackupEditPairGroup);
});
test('handleEditPair: does nothing for a non edit-pair upload', () async {
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: false, livePhotoVideoId: '');
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
TaskStatus.complete,
null,
'{"id":"remote-1"}',
);
await sut.handleEditPair(update, metadata);
verifyNever(() => mockUploadRepository.enqueueBackgroundAll(any()));
});
test('recordPriorRemoteIdOnSuccess: marks the local synced with the uploaded id', () async {
final asset = LocalAssetStub.image1;
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '');
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
TaskStatus.complete,
null,
'{"id":"remote-1"}',
);
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
when(
() => mockLocalAssetRepository.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
verify(
() => mockLocalAssetRepository.markSynced(
asset.id,
priorRemoteId: 'remote-1',
syncedChecksum: asset.checksum ?? '',
),
).called(1);
});
test('recordPriorRemoteIdOnSuccess: skips edit-pair base uploads', () async {
const metadata = UploadTaskMetadata(
localAssetId: 'local-1',
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
);
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
TaskStatus.complete,
null,
'{"id":"base-remote-1"}',
);
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
verifyNever(
() => mockLocalAssetRepository.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
});
group('getLivePhotoUploadTask', () { group('getLivePhotoUploadTask', () {
test('should call getOriginalFilename for live photo upload task', () async { test('should call getOriginalFilename for live photo upload task', () async {
final asset = LocalAssetStub.image1; final asset = LocalAssetStub.image1;
@@ -172,6 +357,8 @@ void main() {
mockLocalAssetRepository, mockLocalAssetRepository,
mockBackupRepository, mockBackupRepository,
mockAssetMediaRepository, mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
); );
addTearDown(() => sutWithV24.dispose()); addTearDown(() => sutWithV24.dispose());
@@ -222,6 +409,8 @@ void main() {
mockLocalAssetRepository, mockLocalAssetRepository,
mockBackupRepository, mockBackupRepository,
mockAssetMediaRepository, mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
); );
addTearDown(() => sutAndroid.dispose()); addTearDown(() => sutAndroid.dispose());
@@ -262,6 +451,8 @@ void main() {
mockLocalAssetRepository, mockLocalAssetRepository,
mockBackupRepository, mockBackupRepository,
mockAssetMediaRepository, mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
); );
addTearDown(() => sutWithV24.dispose()); addTearDown(() => sutWithV24.dispose());
@@ -302,6 +493,8 @@ void main() {
mockLocalAssetRepository, mockLocalAssetRepository,
mockBackupRepository, mockBackupRepository,
mockAssetMediaRepository, mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
); );
addTearDown(() => sutWithV24.dispose()); addTearDown(() => sutWithV24.dispose());
+5
View File
@@ -4,11 +4,14 @@ import 'package:mocktail/mocktail.dart' as mocktail;
import '../domain/service.mock.dart'; import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart'; import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class UnitMocks { class UnitMocks {
final localAlbum = MockLocalAlbumRepository(); final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository(); final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository(); final trashedAsset = MockTrashedLocalAssetRepository();
final stack = MockDriftStackRepository();
final assetApi = MockAssetApiRepository();
final nativeApi = MockNativeSyncApi(); final nativeApi = MockNativeSyncApi();
@@ -31,6 +34,8 @@ class UnitMocks {
mocktail.reset(localAlbum); mocktail.reset(localAlbum);
mocktail.reset(localAsset); mocktail.reset(localAsset);
mocktail.reset(trashedAsset); mocktail.reset(trashedAsset);
mocktail.reset(stack);
mocktail.reset(assetApi);
mocktail.reset(nativeApi); mocktail.reset(nativeApi);
} }
} }
@@ -0,0 +1,117 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
import '../mocks.dart';
void main() {
late EditRevertService sut;
final mocks = UnitMocks();
LocalAsset asset({String? priorRemoteId, String? checksum = 'reverted-sha1'}) => LocalAsset(
id: 'local-1',
name: 'photo.jpg',
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
priorRemoteId: priorRemoteId,
checksum: checksum,
);
setUp(() {
sut = EditRevertService(
nativeSyncApi: mocks.nativeApi,
stackRepository: mocks.stack,
localAssetRepository: mocks.localAsset,
assetApiRepository: mocks.assetApi,
);
});
tearDown(() {
mocks.reset();
});
group('tryHandleRevert', () {
test('returns false when the asset was never uploaded as an edit', () async {
expect(await sut.tryHandleRevert(asset(priorRemoteId: null)), isFalse);
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('returns false (lets the pair flow run) when there is still a live edit', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.edited);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the edit state cannot be read (offloaded to iCloud)', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.unknown);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the prior remote is not in a stack', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('returns false when the stack has no base member to flip back to', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('flips the primary back to the base via prior_remote_id and keeps the edit (no trash)', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(
() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit'),
).thenAnswer((_) async => 'remote-base');
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isTrue);
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
verify(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).called(1);
// Nothing is trashed or unstacked; every edit stays in the stack.
verifyNever(() => mocks.assetApi.delete(any(), any()));
verifyNever(() => mocks.assetApi.unStack(any()));
});
});
}
@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@@ -18,6 +20,8 @@ void main() {
localAssetRepository: mocks.localAsset, localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi, nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset, trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
); );
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {}); when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
@@ -110,6 +114,8 @@ void main() {
nativeSyncApi: mocks.nativeApi, nativeSyncApi: mocks.nativeApi,
batchSize: batchSize, batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset, trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
); );
final album = LocalAlbumFactory.create(); final album = LocalAlbumFactory.create();
@@ -183,5 +189,61 @@ void main() {
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1); verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
}); });
}); });
group('iOS revert reconcile', () {
test('flips the stack primary for a non-styled revert that re-hashed to the base', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
registerFallbackValue(<String>[]);
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
const target = StackReconcileTarget(
stackId: 'stack-1',
newPrimaryId: 'base-1',
localAssetId: 'local-1',
localAssetChecksum: 'reverted-sha1',
);
when(() => mocks.stack.findRevertReconcileTargets(any())).thenAnswer((_) async => [target]);
when(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
when(() => mocks.stack.setPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
when(
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
).thenAnswer((_) async {});
await sut.hashAssets();
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).called(1);
verify(() => mocks.stack.setPrimary('stack-1', 'base-1')).called(1);
verify(
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
).called(1);
});
test('does not reconcile on a non-iOS platform', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
registerFallbackValue(<String>[]);
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.trashedAsset.getAssetsToHash(any())).thenAnswer((_) async => []);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
await sut.hashAssets();
verifyNever(() => mocks.stack.findRevertReconcileTargets(any()));
});
});
}); });
} }
+6
View File
@@ -16490,6 +16490,12 @@
"format": "binary", "format": "binary",
"type": "string" "type": "string"
}, },
"stackParentId": {
"description": "Stack this asset onto the parent asset, with the new asset as the stack primary",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"visibility": { "visibility": {
"$ref": "#/components/schemas/AssetVisibility" "$ref": "#/components/schemas/AssetVisibility"
} }
+2
View File
@@ -630,6 +630,8 @@ export type AssetMediaCreateDto = {
metadata?: AssetMetadataUpsertItemDto[]; metadata?: AssetMetadataUpsertItemDto[];
/** Sidecar file data */ /** Sidecar file data */
sidecarData?: Blob; sidecarData?: Blob;
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
stackParentId?: string;
visibility?: AssetVisibility; visibility?: AssetVisibility;
}; };
export type AssetMediaResponseDto = { export type AssetMediaResponseDto = {
+4
View File
@@ -48,6 +48,10 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
isFavorite: stringToBool.optional().describe('Mark as favorite'), isFavorite: stringToBool.optional().describe('Mark as favorite'),
visibility: AssetVisibilitySchema.optional(), visibility: AssetVisibilitySchema.optional(),
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'), livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
stackParentId: z
.uuidv4()
.optional()
.describe('Stack this asset onto the parent asset, with the new asset as the stack primary'),
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'), metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
[UploadFieldName.SIDECAR_DATA]: z [UploadFieldName.SIDECAR_DATA]: z
.any() .any()
+58 -1
View File
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { StackTable } from 'src/schema/tables/stack.table'; import { StackTable } from 'src/schema/tables/stack.table';
import { asUuid, withDefaultVisibility } from 'src/utils/database'; import { asUuid, isStackPrimaryConstraint, withDefaultVisibility } from 'src/utils/database';
export interface StackSearch { export interface StackSearch {
ownerId: string; ownerId: string;
@@ -124,6 +124,63 @@ export class StackRepository {
}); });
} }
async linkAsset(
ownerId: string,
newAssetId: string,
parentId: string,
): Promise<{ stackId: string; created: boolean } | null> {
try {
return await this.db.transaction().execute(async (tx) => {
// Lock the parent so two concurrent uploads can't each create a stack for it.
const parent = await tx
.selectFrom('asset')
.select(['id', 'ownerId', 'stackId', 'deletedAt'])
.where('id', '=', asUuid(parentId))
.forUpdate()
.executeTakeFirst();
if (!parent || parent.ownerId !== ownerId || parent.deletedAt) {
return null;
}
if (parent.stackId) {
await tx
.updateTable('asset')
.set({ stackId: parent.stackId, updatedAt: new Date() })
.where('id', '=', asUuid(newAssetId))
.execute();
await tx
.updateTable('stack')
.set({ primaryAssetId: newAssetId, updatedAt: new Date() })
.where('id', '=', parent.stackId)
.execute();
return { stackId: parent.stackId, created: false };
}
const stack = await tx
.insertInto('stack')
.values({ ownerId, primaryAssetId: newAssetId })
.returning('id')
.executeTakeFirstOrThrow();
await tx
.updateTable('asset')
.set({ stackId: stack.id, updatedAt: new Date() })
.where('id', 'in', [asUuid(newAssetId), parent.id])
.execute();
return { stackId: stack.id, created: true };
});
} catch (error) {
// newAssetId may already be another stack's primary (e.g. a retried upload) —
// treat the unique-constraint hit as "couldn't stack" rather than a 500.
if (isStackPrimaryConstraint(error)) {
return null;
}
throw error;
}
}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute(); await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
@@ -418,6 +418,79 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
it('should stack a new asset onto the parent and emit the populated stackId', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const parent = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
mocks.asset.create.mockResolvedValue(assetEntity);
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: true });
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'id_1',
status: AssetMediaStatus.CREATED,
});
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, assetEntity.id, parent.id);
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
asset: expect.objectContaining({ stackId: 'stack-1' }),
});
});
it('should reject stacking onto a trashed asset', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const parent = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce({ ...getForAsset(parent), deletedAt: new Date() });
await expect(
sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.stack.linkAsset).not.toHaveBeenCalled();
});
it('should adopt a duplicate into the stack when stacking', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 0,
};
const parent = AssetFactory.create();
const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
mocks.asset.create.mockRejectedValue(error);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('dup-id');
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: false });
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'dup-id',
status: AssetMediaStatus.DUPLICATE,
});
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, 'dup-id', parent.id);
});
it('should hide the linked motion asset', async () => { it('should hide the linked motion asset', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build(); const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const asset = AssetFactory.create(); const asset = AssetFactory.create();
+56 -4
View File
@@ -140,26 +140,63 @@ export class AssetMediaService extends BaseService {
this.requireQuota(auth, file.size); this.requireQuota(auth, file.size);
if (dto.stackParentId) {
if (auth.sharedLink) {
throw new BadRequestException('Cannot stack an asset uploaded via shared link');
}
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.stackParentId] });
const parent = await this.assetRepository.getById(dto.stackParentId);
if (!parent || parent.deletedAt) {
throw new BadRequestException('Cannot stack onto a trashed or missing asset');
}
}
if (dto.livePhotoVideoId) { if (dto.livePhotoVideoId) {
await onBeforeLink( await onBeforeLink(
{ asset: this.assetRepository, event: this.eventRepository }, { asset: this.assetRepository, event: this.eventRepository },
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
); );
} }
const asset = await this.create(auth.user.id, dto, file, sidecarFile); // When stacking, defer the AssetCreate event and emit it below with the
// populated stackId, so clients don't briefly see the asset as standalone.
const asset = await this.create(auth.user.id, dto, file, sidecarFile, { skipEventEmit: !!dto.stackParentId });
if (auth.sharedLink) { if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, asset.id); await this.addToSharedLink(auth.sharedLink, asset.id);
} }
if (dto.stackParentId) {
const linkResult = await this.linkToStackParent(auth.user.id, asset.id, dto.stackParentId);
await this.eventRepository.emit('AssetCreate', {
asset: linkResult ? { ...asset, stackId: linkResult.stackId } : asset,
});
}
await this.userRepository.updateUsage(auth.user.id, file.size); await this.userRepository.updateUsage(auth.user.id, file.size);
return { id: asset.id, status: AssetMediaStatus.CREATED }; return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) { } catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile); return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
} }
} }
private async linkToStackParent(
ownerId: string,
newAssetId: string,
parentId: string,
): Promise<{ stackId: string; created: boolean } | null> {
const result = await this.stackRepository.linkAsset(ownerId, newAssetId, parentId);
if (!result) {
this.logger.warn(`Could not link asset ${newAssetId} to stack parent ${parentId}: parent missing or not owned`);
return null;
}
await this.eventRepository.emit(result.created ? 'StackCreate' : 'StackUpdate', {
stackId: result.stackId,
userId: ownerId,
});
return result;
}
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> { async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -290,6 +327,7 @@ export class AssetMediaService extends BaseService {
auth: AuthDto, auth: AuthDto,
file: UploadFile, file: UploadFile,
sidecarFile?: UploadFile, sidecarFile?: UploadFile,
stackParentId?: string,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
// clean up files // clean up files
await this.jobRepository.queue({ await this.jobRepository.queue({
@@ -309,6 +347,12 @@ export class AssetMediaService extends BaseService {
await this.addToSharedLink(auth.sharedLink, duplicateId); await this.addToSharedLink(auth.sharedLink, duplicateId);
} }
if (stackParentId) {
// Adopt the existing duplicate into the stack so a re-uploaded edit still
// stacks instead of silently staying separate.
await this.linkToStackParent(auth.user.id, duplicateId, stackParentId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`); this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
} }
@@ -317,7 +361,13 @@ export class AssetMediaService extends BaseService {
throw error; throw error;
} }
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) { private async create(
ownerId: string,
dto: AssetMediaCreateDto,
file: UploadFile,
sidecarFile?: UploadFile,
options?: { skipEventEmit?: boolean },
) {
const asset = await this.assetRepository.create({ const asset = await this.assetRepository.create({
ownerId, ownerId,
libraryId: null, libraryId: null,
@@ -356,7 +406,9 @@ export class AssetMediaService extends BaseService {
lockedPropertiesBehavior: 'override', lockedPropertiesBehavior: 'override',
}); });
await this.eventRepository.emit('AssetCreate', { asset }); if (!options?.skipEventEmit) {
await this.eventRepository.emit('AssetCreate', { asset });
}
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
+6
View File
@@ -76,6 +76,12 @@ export const isAssetChecksumConstraint = (error: unknown) => {
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum'; return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
}; };
export const STACK_PRIMARY_CONSTRAINT = 'stack_primaryAssetId_uq';
export const isStackPrimaryConstraint = (error: unknown) => {
return (error as PostgresError)?.constraint_name === STACK_PRIMARY_CONSTRAINT;
};
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) { export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]); return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
} }