Compare commits

...

2 Commits

Author SHA1 Message Date
Santo Shakil 3e6d175735 fix chain resume over deleted remotes and revert reporting 2026-06-11 20:31:58 +06:00
Santo Shakil 9172397b41 feat(mobile): stack original + edited photo on ios 2026-06-11 03:15:17 +06:00
68 changed files with 21872 additions and 242 deletions
+1
View File
@@ -2415,6 +2415,7 @@
"upload": "Upload",
"upload_concurrency": "Upload concurrency",
"upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}",
"upload_deferred_edit_pair": "Waiting for the original photo, will retry automatically",
"upload_details": "Upload Details",
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
"upload_dialog_title": "Upload Asset",
@@ -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. */
data class PlatformAsset (
val id: String,
@@ -472,6 +484,82 @@ data class CloudIdResult (
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class BaseResource (
val path: String,
val sha1: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
val path = pigeonVar_list[0] as String
val sha1 = pigeonVar_list[1] as String
return BaseResource(path, sha1)
}
}
fun toList(): List<Any?> {
return listOf(
path,
sha1,
)
}
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)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class BaseLivePhoto (
val still: BaseResource,
val video: BaseResource? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BaseLivePhoto {
val still = pigeonVar_list[0] as BaseResource
val video = pigeonVar_list[1] as BaseResource?
return BaseLivePhoto(still, video)
}
}
fun toList(): List<Any?> {
return listOf(
still,
video,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as BaseLivePhoto
return MessagesPigeonUtils.deepEquals(this.still, other.still) && MessagesPigeonUtils.deepEquals(this.video, other.video)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + MessagesPigeonUtils.deepHash(this.still)
result = 31 * result + MessagesPigeonUtils.deepHash(this.video)
return result
}
}
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -481,30 +569,45 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it)
return (readValue(buffer) as Long?)?.let {
EditState.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it)
PlatformAsset.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
PlatformAlbum.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
SyncDelta.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BaseResource.fromList(it)
}
}
137.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BaseLivePhoto.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -514,26 +617,38 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
is PlatformAsset -> {
is EditState -> {
stream.write(130)
writeValue(stream, value.toList())
writeValue(stream, value.raw.toLong())
}
is PlatformAlbum -> {
is PlatformAsset -> {
stream.write(131)
writeValue(stream, value.toList())
}
is SyncDelta -> {
is PlatformAlbum -> {
stream.write(132)
writeValue(stream, value.toList())
}
is HashResult -> {
is SyncDelta -> {
stream.write(133)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
is HashResult -> {
stream.write(134)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(135)
writeValue(stream, value.toList())
}
is BaseResource -> {
stream.write(136)
writeValue(stream, value.toList())
}
is BaseLivePhoto -> {
stream.write(137)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -556,6 +671,9 @@ interface NativeSyncApi {
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
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)
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit)
companion object {
/** The codec used by NativeSyncApi. */
@@ -818,6 +936,69 @@ interface NativeSyncApi {
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)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$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.getBaseLivePhoto(assetIdArg, allowNetworkAccessArg) { result: Result<BaseLivePhoto?> ->
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)
}
}
}
}
}
@@ -509,4 +509,19 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList()
}
// Android has no Photos-style edit original to stack; iOS-only.
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
completeWhenActive(callback, Result.success(null))
}
// iOS-only; Android assets never carry a Photos-style edit.
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
completeWhenActive(callback, Result.success(EditState.NOT_EDITED))
}
// iOS-only; Android assets never carry a Photos-style live edit.
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit) {
completeWhenActive(callback, Result.success(null))
}
}
File diff suppressed because it is too large Load Diff
-18
View File
@@ -11,24 +11,6 @@ import Foundation
#error("Unsupported platform.")
#endif
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
+170 -10
View File
@@ -183,6 +183,12 @@ enum PlatformAssetPlaybackStyle: Int {
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.
struct PlatformAsset: Hashable {
var id: String
@@ -458,6 +464,78 @@ struct CloudIdResult: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct BaseResource: Hashable {
var path: String
var sha1: 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
return BaseResource(
path: path,
sha1: sha1
)
}
func toList() -> [Any?] {
return [
path,
sha1,
]
}
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)
}
func hash(into hasher: inout Hasher) {
hasher.combine("BaseResource")
deepHashMessages(value: path, hasher: &hasher)
deepHashMessages(value: sha1, hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct BaseLivePhoto: Hashable {
var still: BaseResource
var video: BaseResource? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BaseLivePhoto? {
let still = pigeonVar_list[0] as! BaseResource
let video: BaseResource? = nilOrValue(pigeonVar_list[1])
return BaseLivePhoto(
still: still,
video: video
)
}
func toList() -> [Any?] {
return [
still,
video,
]
}
static func == (lhs: BaseLivePhoto, rhs: BaseLivePhoto) -> Bool {
if Swift.type(of: lhs) != Swift.type(of: rhs) {
return false
}
return deepEqualsMessages(lhs.still, rhs.still) && deepEqualsMessages(lhs.video, rhs.video)
}
func hash(into hasher: inout Hasher) {
hasher.combine("BaseLivePhoto")
deepHashMessages(value: still, hasher: &hasher)
deepHashMessages(value: video, hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -468,15 +546,25 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
}
return nil
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:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
return PlatformAsset.fromList(self.readValue() as! [Any?])
case 132:
return SyncDelta.fromList(self.readValue() as! [Any?])
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 133:
return HashResult.fromList(self.readValue() as! [Any?])
return SyncDelta.fromList(self.readValue() as! [Any?])
case 134:
return HashResult.fromList(self.readValue() as! [Any?])
case 135:
return CloudIdResult.fromList(self.readValue() as! [Any?])
case 136:
return BaseResource.fromList(self.readValue() as! [Any?])
case 137:
return BaseLivePhoto.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -488,21 +576,30 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
if let value = value as? PlatformAssetPlaybackStyle {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
} else if let value = value as? EditState {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
} else if let value = value as? PlatformAlbum {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
} else if let value = value as? SyncDelta {
super.writeByte(133)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
} else if let value = value as? HashResult {
super.writeByte(134)
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 if let value = value as? BaseLivePhoto {
super.writeByte(137)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -540,6 +637,9 @@ protocol NativeSyncApi {
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
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)
func getBaseLivePhoto(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -773,5 +873,65 @@ class NativeSyncApiSetup {
} else {
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)
}
let getBaseLivePhotoChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getBaseLivePhotoChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getBaseLivePhoto(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getBaseLivePhotoChannel.setMessageHandler(nil)
}
}
}
+299
View File
@@ -1,5 +1,6 @@
import Photos
import CryptoKit
import UniformTypeIdentifiers
struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset
@@ -476,4 +477,302 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return mappings;
}
func getBaseResource(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<BaseResource?, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
do {
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
let result = try await self.streamBaseResource(
resource: originals.still,
localId: assetId,
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(result))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
// Reads both readable originals of an edited live photo (still + paired video) so the
// backup can upload the unedited pair and stack the edit onto it. Same edited-only gate
// as getBaseResource. video is nil when the asset has no paired video left to recover
// (e.g. the edit turned Live off); the still temp is removed if the video read fails.
func getBaseLivePhoto(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
do {
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
let still = try await self.streamBaseResource(
resource: originals.still,
localId: assetId,
allowNetworkAccess: allowNetworkAccess
)
var video: BaseResource? = nil
if let videoRes = originals.video {
do {
video = try await self.streamBaseResource(
resource: videoRes,
localId: assetId,
allowNetworkAccess: allowNetworkAccess
)
} catch {
try? FileManager.default.removeItem(atPath: still.path)
throw error
}
}
self.completeWhenActive(for: completion, with: .success(BaseLivePhoto(still: still, video: video)))
} 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
// Idle deadline for the base-resource reads: cancel only after this long with no
// data received, so a stalled iCloud fetch can't hang the backup forever but a
// big original on a slow link keeps downloading as long as chunks flow.
private static let kBaseReadTimeoutSeconds: Double = 120
private final class ResourceRequestRef {
var id: PHAssetResourceDataRequestID?
// Written from the resource callback queue, read from the deadline timer;
// unsynchronized on purpose the read below clamps, so the worst case is
// the timer re-arming one extra round.
var lastActivity = DispatchTime.now()
}
// Re-arming watchdog: fires after `delay`, cancels if nothing arrived for a full
// timeout window, otherwise re-arms for the remainder of the window.
private static func armIdleDeadline(_ ref: ResourceRequestRef, after delay: Double = kBaseReadTimeoutSeconds) {
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
guard let id = ref.id else { return }
let nowNs = DispatchTime.now().uptimeNanoseconds
let lastNs = ref.lastActivity.uptimeNanoseconds
// lastActivity can race ahead of the captured now; treat that as activity.
let idle = nowNs > lastNs ? Double(nowNs - lastNs) / 1_000_000_000 : 0
if idle >= kBaseReadTimeoutSeconds {
PHAssetResourceManager.default().cancelDataRequest(id)
} else {
armIdleDeadline(ref, after: kBaseReadTimeoutSeconds - idle)
}
}
}
// Shared gate for the base readers: fetch the asset, classify the edit from its
// adjustment metadata, and pick the original resources. nil = positively nothing
// to recover (missing asset, not edited, or no readable original still). An
// unreadable plist throws instead that's "can't tell right now", and Dart
// defers the asset rather than uploading the edit standalone for good.
private static func originalsForEditedAsset(
_ assetId: String,
allowNetworkAccess: Bool
) async throws -> (still: PHAssetResource, video: PHAssetResource?)? {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
return nil
}
let resources = PHAssetResource.assetResources(for: asset)
let state = await classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
if state == .unknown {
throw PigeonError(
code: "unknownEditState",
message: "Could not read adjustment metadata for \(assetId)",
details: nil
)
}
guard state == .edited, let still = originalStillResource(resources) else {
return nil
}
return (still, originalPairedVideoResource(resources))
}
// Works out the edit state from Adjustments.plist only (never reads the photo).
// adjustmentRenderTypes is the signal: a real edit moves it off the baseline, while a
// plain capture, a Photographic Style, and a reverted edit all sit at the baseline. The
// editor id is NOT reliable: com.apple.camera authors both styles and some real edits
// (e.g. changing the Photographic Style after capture), so we key off the render types
// alone. Cleanup and object-removal 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 renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
let isUserEdit = renderTypes != nil && renderTypes != kNoEditRenderTypes
return isUserEdit ? .edited : .notEdited
}
// The unedited original still, told apart from the edited "current" render by isCurrent.
// Prefer the non-current .photo; fall back to the .adjustmentBasePhoto flavor some
// creation-API / third-party-editor layouts use for the unaltered source (their .photo
// IS the edited render, so this must come before the bare .photo net); last, a lone
// .photo for single-resource assets or a failed isCurrent read.
private static func originalStillResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
return resources.first(where: { $0.type == .photo && !$0.isCurrent })
?? resources.first(where: { $0.type == .adjustmentBasePhoto })
?? resources.first(where: { $0.type == .photo })
}
// The unedited original paired video, same isCurrent / adjustment-base ordering as the
// still. nil when the asset carries no paired video (not live, or Live turned off).
private static func originalPairedVideoResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
return resources.first(where: { $0.type == .pairedVideo && !$0.isCurrent })
?? resources.first(where: { $0.type == .adjustmentBasePairedVideo })
?? resources.first(where: { $0.type == .pairedVideo })
}
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"
// Library/Caches, not tmp: the chain can span launches and clearCache wipes
// tmp at the start of every upload run. Swept by clearEditBaseCache instead.
let cacheRoot =
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let tempDir = cacheRoot.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 {
try? FileManager.default.removeItem(at: tempUrl)
throw PigeonError(
code: "baseResourceWriteFailed",
message: "Failed to open temp file for base resource \(localId)",
details: nil
)
}
var hasher = Insecure.SHA1()
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
// Deadline + cancellation so a stalled iCloud read can't hang the backup forever;
// a write failure also cancels right away instead of draining the download for nothing.
let requestRef = ResourceRequestRef()
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
var writeFailed = false
requestRef.id = PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { chunk in
requestRef.lastActivity = DispatchTime.now()
if writeFailed { return }
do {
try handle.write(contentsOf: chunk)
hasher.update(data: chunk)
} catch {
writeFailed = true
if let id = requestRef.id {
PHAssetResourceManager.default().cancelDataRequest(id)
}
}
},
completionHandler: { error in
requestRef.id = nil
continuation.resume(returning: error == nil && !writeFailed)
}
)
Self.armIdleDeadline(requestRef)
}
try? handle.close()
guard succeeded else {
try? FileManager.default.removeItem(at: tempUrl)
throw PigeonError(
code: "baseResourceReadFailed",
message: "Failed to read base resource for \(localId)",
details: nil
)
}
let sha1 = Data(hasher.finalize()).base64EncodedString()
return BaseResource(path: tempUrl.path, sha1: sha1)
}
private static func collectResourceData(
_ resource: PHAssetResource,
allowNetworkAccess: Bool
) async -> Data? {
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
var buffer = Data()
let requestRef = ResourceRequestRef()
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
requestRef.id = PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { data in
requestRef.lastActivity = DispatchTime.now()
buffer.append(data)
},
completionHandler: { error in
requestRef.id = nil
continuation.resume(returning: error == nil ? buffer : nil)
}
)
armIdleDeadline(requestRef)
}
}
}
+10
View File
@@ -20,6 +20,16 @@ const String kSecuredPinCode = "secured_pin_code";
const String kManualUploadGroup = 'manual_upload_group';
const String kBackupGroup = 'backup_group';
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
const String kBackupEditPairGroup = 'backup_edit_pair_group';
// Upload multipart 'visibility' value for motion videos (server AssetVisibility.Hidden)
// so they never flash onto the timeline before their still links them.
const String kHiddenVisibility = 'hidden';
// Server's 400 message when stackParentId points at a trashed/deleted asset
// (asset-media.service.ts). Matching it clears the stale prior stamps so the
// next backup cycle re-resolves instead of looping on the same dead id.
const String kDeadStackParentError = 'Cannot stack onto a trashed or missing asset';
const String kDownloadGroupImage = 'group_image';
const String kDownloadGroupVideo = 'group_video';
const String kDownloadGroupLivePhoto = 'group_livephoto';
@@ -12,6 +12,13 @@ class LocalAsset extends BaseAsset {
final double? latitude;
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({
required this.id,
String? remoteId,
@@ -32,6 +39,8 @@ class LocalAsset extends BaseAsset {
this.latitude,
this.longitude,
required super.isEdited,
this.priorRemoteId,
this.syncedChecksum,
}) : remoteAssetId = remoteId;
@override
@@ -120,6 +129,8 @@ class LocalAsset extends BaseAsset {
double? latitude,
double? longitude,
bool? isEdited,
String? priorRemoteId,
String? syncedChecksum,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -140,6 +151,8 @@ class LocalAsset extends BaseAsset {
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
);
}
}
@@ -13,6 +13,11 @@ class RemoteAsset extends BaseAsset {
final DateTime? uploadedAt;
final DateTime? deletedAt;
// The linked local's current checksum. Differs from [checksum] when the link
// came via priorRemoteId (the local re-encoded on device, e.g. a revert); local
// renders are cache-keyed by this so on-device changes aren't shown stale.
final String? localChecksum;
const RemoteAsset({
required this.id,
String? localId,
@@ -33,6 +38,7 @@ class RemoteAsset extends BaseAsset {
this.stackId,
required super.isEdited,
this.deletedAt,
this.localChecksum,
}) : localAssetId = localId;
@override
@@ -91,7 +97,8 @@ class RemoteAsset extends BaseAsset {
visibility == other.visibility &&
stackId == other.stackId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
deletedAt == other.deletedAt &&
localChecksum == other.localChecksum;
}
@override
@@ -104,7 +111,8 @@ class RemoteAsset extends BaseAsset {
visibility.hashCode ^
stackId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
deletedAt.hashCode ^
localChecksum.hashCode;
RemoteAsset copyWith({
String? id,
@@ -126,6 +134,7 @@ class RemoteAsset extends BaseAsset {
String? stackId,
bool? isEdited,
DateTime? deletedAt,
String? localChecksum,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -147,6 +156,7 @@ class RemoteAsset extends BaseAsset {
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
localChecksum: localChecksum ?? this.localChecksum,
);
}
}
@@ -174,6 +184,7 @@ class RemoteAssetExif extends RemoteAsset {
super.livePhotoVideoId,
super.stackId,
super.isEdited = false,
super.localChecksum,
this.exifInfo = const ExifInfo(),
});
@@ -212,6 +223,7 @@ class RemoteAssetExif extends RemoteAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
String? localChecksum,
ExifInfo? exifInfo,
}) {
return RemoteAssetExif(
@@ -234,6 +246,7 @@ class RemoteAssetExif extends RemoteAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
localChecksum: localChecksum ?? this.localChecksum,
exifInfo: exifInfo ?? this.exifInfo, // Use the new parameter
);
}
@@ -0,0 +1,95 @@
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 this._nativeSyncApi,
required this._stackRepository,
required this._localAssetRepository,
required this._assetApiRepository,
});
/// Returns the remote id the stack cover was flipped back to when the asset
/// was a revert and was handled (caller skips the upload and can report that
/// id); null to fall through to the normal upload path.
Future<String?> tryHandleRevert(LocalAsset asset) async {
if (asset.priorRemoteId == null) {
return null;
}
// 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)
.timeout(const Duration(seconds: 30));
if (editState != EditState.notEdited) {
return null;
}
} catch (error, stack) {
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
return null;
}
// 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 null;
}
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
if (base == null) {
return null;
}
stackId = foundStack;
baseId = base;
} catch (error, stack) {
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
return null;
}
try {
await _assetApiRepository.setStackPrimary(stackId, baseId);
} catch (error, stack) {
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
return null;
}
// The server flip is what makes the revert handled. If the local writes fail,
// falling through would upload the reverted render as a brand-new edit — the
// opposite of the user's action — so log and let checkpoint sync heal local state.
try {
await _stackRepository.setPrimary(stackId, baseId);
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum);
} catch (error, stack) {
_log.warning("revert local reconcile failed for ${asset.id}", error, stack);
}
return baseId;
}
}
@@ -7,8 +7,10 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.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_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/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
const String _kHashCancelledCode = "HASH_CANCELLED";
@@ -20,6 +22,8 @@ class HashService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final Completer<void>? _cancellation;
final DriftStackRepository _stackRepository;
final AssetApiRepository _assetApiRepository;
final _log = Logger('HashService');
HashService({
@@ -28,6 +32,8 @@ class HashService {
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
this._cancellation,
required this._stackRepository,
required this._assetApiRepository,
int? batchSize,
}) : _batchSize = batchSize ?? kBatchHashFileLimit {
// Stop the in-flight native hash call promptly on cancellation; the loops
@@ -66,6 +72,17 @@ class HashService {
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
}
}
// 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). Runs every cycle, not just when something
// hashed: a flip that failed (offline at hash time) has no second hash to ride,
// and the stack-driven target query is cheap and self-limiting.
if (CurrentPlatform.isIOS && !isCancelled) {
await _reconcileReverts();
}
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
@@ -143,4 +160,30 @@ class HashService {
await _localAssetRepository.updateHashes(hashed);
}
}
Future<void> _reconcileReverts() async {
final List<StackReconcileTarget> targets;
try {
targets = await _stackRepository.findRevertReconcileTargets();
} 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);
}
}
}
}
@@ -360,7 +360,7 @@ class SyncStreamService {
}
if (assets.isNotEmpty && exifs.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch');
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
_logger.info('Successfully processed ${assets.length} assets in batch');
}
@@ -403,7 +403,7 @@ class SyncStreamService {
}
if (assets.isNotEmpty && exifs.isNotEmpty) {
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
_logger.info('Successfully processed ${assets.length} assets in batch');
}
@@ -444,7 +444,7 @@ class SyncStreamService {
.toList();
}
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
_logger.info(
@@ -482,7 +482,7 @@ class SyncStreamService {
.whereType<SyncAssetEditV1>()
.toList();
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
_logger.info(
@@ -160,6 +160,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) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity();
@@ -28,6 +29,14 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
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
Set<Column> get primaryKey => {id};
}
@@ -52,5 +61,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
longitude: longitude,
cloudId: iCloudId,
isEdited: false,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
);
}
@@ -26,6 +26,8 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -45,6 +47,8 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
class $$LocalAssetEntityTableFilterComposer
@@ -141,6 +145,16 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.playbackStyle,
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
@@ -231,6 +245,16 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.playbackStyle,
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
@@ -300,6 +324,16 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.playbackStyle,
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
@@ -359,6 +393,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -376,6 +412,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
createCompanionCallback:
({
@@ -396,6 +434,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -413,6 +453,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -637,6 +679,28 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
).withConverter<i2.AssetPlaybackStyle>(
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
List<i0.GeneratedColumn> get $columns => [
name,
@@ -655,6 +719,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -759,6 +825,24 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
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;
}
@@ -839,6 +923,14 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
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? longitude;
final i2.AssetPlaybackStyle playbackStyle;
final String? priorRemoteId;
final String? syncedChecksum;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -894,6 +988,8 @@ class LocalAssetEntityData extends i0.DataClass
this.latitude,
this.longitude,
required this.playbackStyle,
this.priorRemoteId,
this.syncedChecksum,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -938,6 +1034,12 @@ class LocalAssetEntityData extends i0.DataClass
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;
}
@@ -967,6 +1069,8 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
serializer.fromJson<int>(json['playbackStyle']),
),
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
);
}
@override
@@ -993,6 +1097,8 @@ class LocalAssetEntityData extends i0.DataClass
'playbackStyle': serializer.toJson<int>(
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?> longitude = const i0.Value.absent(),
i2.AssetPlaybackStyle? playbackStyle,
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -1032,6 +1140,12 @@ class LocalAssetEntityData extends i0.DataClass
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId.present
? priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: syncedChecksum.present
? syncedChecksum.value
: this.syncedChecksum,
);
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData(
@@ -1061,6 +1175,12 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: 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('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')'))
.toString();
}
@@ -1105,6 +1227,8 @@ class LocalAssetEntityData extends i0.DataClass
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
);
@override
bool operator ==(Object other) =>
@@ -1125,7 +1249,9 @@ class LocalAssetEntityData extends i0.DataClass
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude &&
other.playbackStyle == this.playbackStyle);
other.playbackStyle == this.playbackStyle &&
other.priorRemoteId == this.priorRemoteId &&
other.syncedChecksum == this.syncedChecksum);
}
class LocalAssetEntityCompanion
@@ -1146,6 +1272,8 @@ class LocalAssetEntityCompanion
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
final i0.Value<String?> priorRemoteId;
final i0.Value<String?> syncedChecksum;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1163,6 +1291,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
});
LocalAssetEntityCompanion.insert({
required String name,
@@ -1181,6 +1311,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = 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),
type = i0.Value(type),
id = i0.Value(id);
@@ -1201,6 +1333,8 @@ class LocalAssetEntityCompanion
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
i0.Expression<int>? playbackStyle,
i0.Expression<String>? priorRemoteId,
i0.Expression<String>? syncedChecksum,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1219,6 +1353,8 @@ class LocalAssetEntityCompanion
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
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?>? longitude,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
i0.Value<String?>? priorRemoteId,
i0.Value<String?>? syncedChecksum,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1257,6 +1395,8 @@ class LocalAssetEntityCompanion
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
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;
}
@@ -1338,7 +1484,9 @@ class LocalAssetEntityCompanion
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')'))
.toString();
}
@@ -1352,3 +1500,7 @@ i0.Index get idxLocalAssetCreatedAt => i0.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
i0.Index get idxLocalAssetPriorRemoteId => i0.Index(
'idx_local_asset_prior_remote_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
);
@@ -7,7 +7,13 @@ import 'local_album_asset.entity.dart';
mergedAsset:
SELECT
rae.id as remote_id,
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
-- edit re-encodes to fresh bytes so the checksum no longer matches, but its
-- prior_remote_id still points at this remote, so fall back to that.
COALESCE(
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
(SELECT lae.id FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
) as local_id,
rae.name,
rae."type",
rae.created_at as created_at,
@@ -18,6 +24,12 @@ SELECT
rae.is_favorite,
rae.thumb_hash,
rae.checksum,
-- the linked local's current checksum (same row local_id picks), so local
-- renders are cache-keyed by the bytes on device, not the server value.
COALESCE(
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
) as local_checksum,
rae.owner_id,
rae.live_photo_video_id,
0 as orientation,
@@ -57,6 +69,7 @@ SELECT
lae.is_favorite,
NULL as thumb_hash,
lae.checksum,
lae.checksum as local_checksum,
NULL as owner_id,
NULL as live_photo_video_id,
lae.orientation,
@@ -83,6 +96,15 @@ AND NOT EXISTS (
INNER JOIN local_album_entity la on laa.album_id = la.id
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 remote row), 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.
-- A trashed prior still hides it — trashing on the server shouldn't pop the
-- photo back onto the local timeline; only a hard delete (row gone) does.
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
LIMIT $limit;
@@ -136,6 +158,11 @@ FROM
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
-- iOS edit-in-progress / revert: hide a local already represented by a remote
-- row (trashed included, same as the tile query above).
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
ORDER BY bucket_date DESC;
+5 -2
View File
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
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, COALESCE((SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id 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, COALESCE((SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_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, lae.checksum AS local_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: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -58,6 +58,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
isFavorite: row.read<bool>('is_favorite'),
thumbHash: row.readNullable<String>('thumb_hash'),
checksum: row.readNullable<String>('checksum'),
localChecksum: row.readNullable<String>('local_checksum'),
ownerId: row.readNullable<String>('owner_id'),
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
orientation: row.read<int>('orientation'),
@@ -81,7 +82,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
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: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),
@@ -132,6 +133,7 @@ class MergedAssetResult {
final bool isFavorite;
final String? thumbHash;
final String? checksum;
final String? localChecksum;
final String? ownerId;
final String? livePhotoVideoId;
final int orientation;
@@ -156,6 +158,7 @@ class MergedAssetResult {
required this.isFavorite,
this.thumbHash,
this.checksum,
this.localChecksum,
this.ownerId,
this.livePhotoVideoId,
required this.orientation,
@@ -55,6 +55,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
}
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
// localId callers attach it via a checksum-equality join, so the local's
// bytes are the remote's — key local renders by the same checksum.
RemoteAsset toDto({String? localId}) => RemoteAsset(
id: id,
name: name,
@@ -72,6 +74,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
visibility: visibility,
livePhotoVideoId: livePhotoVideoId,
localId: localId,
localChecksum: localId == null ? null : checksum,
stackId: stackId,
isEdited: isEdited,
deletedAt: deletedAt,
@@ -34,14 +34,27 @@ class DriftBackupRepository extends DriftDatabaseRepository {
/// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums
/// - backup: number of those assets that already exist on the server for [userId]
/// - remainder: number of those assets that do not yet exist on the server for [userId]
/// (includes processing)
/// (includes processing), excluding handled iOS reverts (syncedChecksum == checksum
/// with the prior upload still on the server — trashed counts, like the
/// checksum arm; only a hard delete re-opens the asset)
/// - processing: number of those assets that are still preparing/have a null checksum
Future<({int total, int remainder, int processing})> getAllCounts(String userId) async {
const sql = '''
SELECT
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE lae.checksum IS NULL) AS processing_count,
COUNT(*) FILTER (WHERE rae.id IS NULL) AS remainder_count
COUNT(*) FILTER (
WHERE rae.id IS NULL
AND (
lae.checksum IS NULL
OR lae.synced_checksum IS NULL
OR lae.synced_checksum != lae.checksum
OR NOT EXISTS (
SELECT 1 FROM main.remote_asset_entity pr
WHERE pr.id = lae.prior_remote_id
)
)
) AS remainder_count
FROM local_asset_entity lae
LEFT JOIN main.remote_asset_entity rae
ON lae.checksum = rae.checksum AND rae.owner_id = ?1
@@ -104,6 +117,20 @@ class DriftBackupRepository extends DriftDatabaseRepository {
_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, so don't re-queue it as a fresh upload. Suppress while
// the prior row exists at all — trashed stays suppressed (same
// convention as the checksum arm above); only a hard-deleted remote
// must become a candidate again.
(lae.checksum.isNull() |
lae.syncedChecksum.isNull() |
lae.syncedChecksum.equalsExp(lae.checksum).not() |
notExistsQuery(
_db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id])
..where(_db.remoteAssetEntity.id.equalsExp(lae.priorRemoteId)),
)) &
lae.id.isNotInQuery(_getExcludedSubquery()),
)
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
@@ -120,7 +120,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 29;
int get schemaVersion => 30;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -308,6 +308,11 @@ class Drift extends $Drift {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.addColumn(v30.localAssetEntity, v30.localAssetEntity.priorRemoteId);
await m.addColumn(v30.localAssetEntity, v30.localAssetEntity.syncedChecksum);
await m.createIndex(v30.idxLocalAssetPriorRemoteId);
},
),
);
@@ -118,6 +118,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i4.idxLocalAssetCreatedAt,
i4.idxLocalAssetPriorRemoteId,
i3.idxStackPrimaryAssetId,
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -15331,6 +15331,650 @@ i1.GeneratedColumn<String> _column_223(String aliasedName) =>
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
final class Schema30 extends i0.VersionedSchema {
Schema30({required super.database}) : super(version: 30);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxLocalAssetPriorRemoteId,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 localAssetEntity = Shape52(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
_column_224,
_column_225,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxLocalAssetPriorRemoteId = i1.Index(
'idx_local_asset_prior_remote_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get priorRemoteId =>
columnsByName['prior_remote_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get syncedChecksum =>
columnsByName['synced_checksum']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_224(String aliasedName) =>
i1.GeneratedColumn<String>(
'prior_remote_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
i1.GeneratedColumn<String>(
'synced_checksum',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -15360,6 +16004,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -15503,6 +16148,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from28To29(migrator, schema);
return 29;
case 29:
final schema = Schema30(database: database);
final migrator = i1.Migrator(database, schema);
await from29To30(migrator, schema);
return 30;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -15538,6 +16188,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -15568,5 +16219,6 @@ i1.OnUpgrade stepByStep({
from26To27: from26To27,
from27To28: from27To28,
from28To29: from28To29,
from29To30: from29To30,
),
);
@@ -64,6 +64,20 @@ 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)),
);
}
/// Drops the edit-stacking stamps so the next backup cycle re-resolves the
/// asset from scratch (used when the server says the stamped prior is gone).
Future<void> clearSyncStamps(String localId) {
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)),
);
}
Future<void> delete(List<String> ids) {
if (ids.isEmpty) {
return Future.value();
@@ -46,7 +46,9 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
final localId = row.read(_db.localAssetEntity.id);
// checksum-equality join: the local's bytes are the remote's
return asset.copyWith(localId: localId, localChecksum: localId == null ? null : asset.checksum);
});
}
@@ -3,6 +3,22 @@ import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.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,
});
}
enum PriorState { live, trashed, missing }
class DriftStackRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftStackRepository(this._db) : super(_db);
@@ -14,6 +30,134 @@ class DriftStackRepository extends DriftDatabaseRepository {
return stack.toDto();
}).get();
}
// Find stacks whose primary should flip back after a revert: a local that was
// uploaded as an edit (prior in the stack) now hashes to a DIFFERENT member
// that isn't the primary. Two discriminators keep this from fighting stacks
// the user arranged by hand: the matched member must not be the local's own
// prior (a true revert has prior = the edit, member = the base), and the
// local must be unreconciled (synced_checksum != checksum — the flip below
// writes synced = checksum, which is what makes this self-limiting). Driven
// from stack_entity so the work scales with the number of stacks (few), and
// runs every hash cycle so a flip that failed offline gets retried.
Future<List<StackReconcileTarget>> findRevertReconcileTargets() async {
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 stack_entity s
INNER JOIN remote_asset_entity member
ON member.stack_id = s.id
AND member.deleted_at IS NULL
INNER JOIN local_asset_entity local
ON local.checksum = member.checksum
AND local.prior_remote_id IS NOT NULL
AND local.prior_remote_id != member.id
AND local.synced_checksum IS NOT local.checksum
INNER JOIN remote_asset_entity prior
ON prior.id = local.prior_remote_id
AND prior.stack_id = s.id
AND prior.deleted_at IS NULL
WHERE s.primary_asset_id != member.id
''',
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
)
.get();
return rows
.map(
(row) => 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'),
),
)
.toList();
}
// What the synced remote table knows about a stamped prior. missing is
// ambiguous: either just uploaded and not synced back yet, or hard-deleted on
// the server — the caller tells them apart via syncedChecksum (null = a chain
// is still mid-flight, so the row simply hasn't synced yet). A locked-folder
// row counts as trashed: the server refuses to stack onto it (and with a
// message the dead-parent belt doesn't match), so defer until it's unlocked.
Future<PriorState> priorState(String remoteId) async {
final row = await _db
.customSelect(
// 3 = locked
'SELECT (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE id = ? LIMIT 1',
variables: [Variable<String>(remoteId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
if (row == null) {
return PriorState.missing;
}
return row.read<bool>('blocked') ? PriorState.trashed : PriorState.live;
}
// The synced remote owned by [ownerId] with these exact bytes, if any. The
// server keys assets by (owner, checksum), so at most one row matches.
// Locked rows count as trashed here too, same reasoning as [priorState].
Future<({PriorState state, String? remoteId})> remoteByChecksum(String checksum, String ownerId) async {
final row = await _db
.customSelect(
// 3 = locked
'SELECT id, (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE checksum = ? AND owner_id = ? LIMIT 1',
variables: [Variable<String>(checksum), Variable<String>(ownerId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
if (row == null) {
return (state: PriorState.missing, remoteId: null);
}
return (state: row.read<bool>('blocked') ? PriorState.trashed : PriorState.live, remoteId: row.read<String>('id'));
}
// 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 {
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
class StorageRepository {
@@ -150,4 +151,34 @@ class StorageRepository {
log.warning("Error deleting temporary directory", error, stackTrace);
}
}
/// Base originals for the edit chain live under Library/Caches (immich_base),
/// not tmp, so [clearCache] can't wipe a chain still in flight across
/// launches. Sweeps only files older than a day: live chains and concurrent
/// foreground pair uploads keep their temps; orphans from dead chains go.
Future<void> clearEditBaseCache() async {
if (!CurrentPlatform.isIOS) {
return;
}
try {
final cache = await getApplicationCacheDirectory();
final dir = Directory('${cache.path}/immich_base');
if (!await dir.exists()) {
return;
}
final cutoff = DateTime.now().subtract(const Duration(days: 1));
await for (final entry in dir.list()) {
try {
final stat = await entry.stat();
if (stat.modified.isBefore(cutoff)) {
await entry.delete(recursive: true);
}
} catch (error, stackTrace) {
log.warning("Error sweeping ${entry.path}", error, stackTrace);
}
}
} catch (error, stackTrace) {
log.warning("Error sweeping edit base cache", error, stackTrace);
}
}
}
@@ -16,6 +16,7 @@ import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dar
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
@@ -71,6 +72,12 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.remoteAssetCloudIdEntity.deleteAll();
await _db.assetEditEntity.deleteAll();
await _db.assetOcrEntity.deleteAll();
// The edit-stacking stamps point at remote rows wiped above; left in
// place they'd make the next backup (possibly a different account or
// server) stack onto ids that no longer exist.
await _db.localAssetEntity.update().write(
const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)),
);
});
} finally {
// re-enable FK even if the transaction throws, otherwise the connection
@@ -195,7 +202,27 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetsV1(Iterable<SyncAssetV1> data, {String debugLabel = 'user'}) async {
// websocket events are a point-in-time snapshot, so on the fast path don't overwrite
// link state the checkpoint sync owns (a motion video uploads visible then gets hidden).
RemoteAssetEntityCompanion _conflictUpdate(RemoteAssetEntityCompanion companion, bool fromWebsocket) {
if (!fromWebsocket) {
return companion;
}
// deletedAt is checkpoint-owned too: a debounced upload-ready snapshot always
// carries null and must not resurrect an asset trashed in the meantime.
return companion.copyWith(
visibility: const Value.absent(),
livePhotoVideoId: const Value.absent(),
stackId: const Value.absent(),
deletedAt: const Value.absent(),
);
}
Future<void> updateAssetsV1(
Iterable<SyncAssetV1> data, {
String debugLabel = 'user',
bool fromWebsocket = false,
}) async {
try {
await _db.batch((batch) {
for (final asset in data) {
@@ -224,7 +251,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
batch.insert(
_db.remoteAssetEntity,
companion.copyWith(id: Value(asset.id)),
onConflict: DoUpdate((_) => companion),
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
);
}
});
@@ -234,7 +261,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetsV2(Iterable<SyncAssetV2> data, {String debugLabel = 'user'}) async {
Future<void> updateAssetsV2(
Iterable<SyncAssetV2> data, {
String debugLabel = 'user',
bool fromWebsocket = false,
}) async {
try {
await _db.batch((batch) {
for (final asset in data) {
@@ -263,7 +294,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
batch.insert(
_db.remoteAssetEntity,
companion.copyWith(id: Value(asset.id)),
onConflict: DoUpdate((_) => companion),
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
);
}
});
@@ -88,6 +88,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId,
isEdited: row.isEdited,
localChecksum: row.localChecksum,
)
: LocalAsset(
id: row.localId!,
+159 -10
View File
@@ -88,6 +88,8 @@ int _deepHash(Object? value) {
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum EditState { notEdited, edited, unknown }
class PlatformAsset {
PlatformAsset({
required this.id,
@@ -395,6 +397,80 @@ class CloudIdResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class BaseResource {
BaseResource({required this.path, required this.sha1});
String path;
String sha1;
List<Object?> _toList() {
return <Object?>[path, sha1];
}
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);
}
@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);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class BaseLivePhoto {
BaseLivePhoto({required this.still, this.video});
BaseResource still;
BaseResource? video;
List<Object?> _toList() {
return <Object?>[still, video];
}
Object encode() {
return _toList();
}
static BaseLivePhoto decode(Object result) {
result as List<Object?>;
return BaseLivePhoto(still: result[0]! as BaseResource, video: result[1] as BaseResource?);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! BaseLivePhoto || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(still, other.still) && _deepEquals(video, other.video);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -405,21 +481,30 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
} else if (value is EditState) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) {
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
} else if (value is PlatformAlbum) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
} else if (value is SyncDelta) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
} else if (value is HashResult) {
buffer.putUint8(134);
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 if (value is BaseLivePhoto) {
buffer.putUint8(137);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@@ -432,15 +517,22 @@ class _PigeonCodec extends StandardMessageCodec {
final value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130:
return PlatformAsset.decode(readValue(buffer)!);
final value = readValue(buffer) as int?;
return value == null ? null : EditState.values[value];
case 131:
return PlatformAlbum.decode(readValue(buffer)!);
return PlatformAsset.decode(readValue(buffer)!);
case 132:
return SyncDelta.decode(readValue(buffer)!);
return PlatformAlbum.decode(readValue(buffer)!);
case 133:
return HashResult.decode(readValue(buffer)!);
return SyncDelta.decode(readValue(buffer)!);
case 134:
return HashResult.decode(readValue(buffer)!);
case 135:
return CloudIdResult.decode(readValue(buffer)!);
case 136:
return BaseResource.decode(readValue(buffer)!);
case 137:
return BaseLivePhoto.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@@ -705,4 +797,61 @@ class NativeSyncApi {
);
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;
}
Future<BaseLivePhoto?> getBaseLivePhoto(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$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 BaseLivePhoto?;
}
}
@@ -159,7 +159,13 @@ ImageProvider getFullImageProvider(
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
provider = LocalFullImageProvider(
id: id,
size: size,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
checksum: _localRenderChecksum(asset),
);
} else {
final String assetId;
final String thumbhash;
@@ -187,7 +193,7 @@ ImageProvider getFullImageProvider(
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: _localRenderChecksum(asset));
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
@@ -195,7 +201,14 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
}
// Cache key for rendering the LOCAL bytes: a remote linked via priorRemoteId carries
// the server checksum, which doesn't move when the on-device bytes change again.
String? _localRenderChecksum(BaseAsset asset) => asset is RemoteAsset ? asset.localChecksum : asset.checksum;
bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal &&
(!asset.hasRemote || !SettingsRepository.instance.appConfig.image.preferRemote) &&
!asset.isEdited;
!asset.isEdited &&
// A prior-linked local that hasn't rehashed yet has no trustworthy cache key
// (its bytes may differ from the server checksum) — render the remote instead.
(asset is! RemoteAsset || asset.localChecksum != null);
@@ -14,7 +14,11 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
final Size size;
final AssetType assetType;
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
// an on-device edit/revert keeps the same id but changes the bytes, so the checksum
// is what keys a cached thumbnail to its render.
final String? checksum;
LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution});
@override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -44,13 +48,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
return true;
}
if (other is LocalThumbProvider) {
return id == other.id;
return id == other.id && checksum == other.checksum;
}
return false;
}
@override
int get hashCode => id.hashCode;
int get hashCode => id.hashCode ^ checksum.hashCode;
}
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
@@ -59,8 +63,15 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size;
final AssetType assetType;
final bool isAnimated;
final String? checksum;
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
required this.isAnimated,
this.checksum,
});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -73,7 +84,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
@@ -86,7 +97,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
@@ -163,11 +174,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return true;
}
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && isAnimated == other.isAnimated;
return id == other.id && size == other.size && isAnimated == other.isAnimated && checksum == other.checksum;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode ^ checksum.hashCode;
}
@@ -4,7 +4,6 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
@@ -380,19 +379,19 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
_logger.info("Start background backup sequence");
state = state.copyWith(error: BackupError.none);
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
final pending = await _backgroundUploadService.getActiveBackupTaskCount();
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} pending tasks");
_logger.info("Found $pending pending tasks");
if (tasks.isEmpty) {
if (pending == 0) {
_logger.info("No pending tasks, starting new upload");
return _backgroundUploadService.uploadBackupCandidates(userId);
}
_logger.info("Resuming upload ${tasks.length} assets");
_logger.info("Resuming upload $pending assets");
return _backgroundUploadService.resume();
}
}
@@ -1,4 +1,5 @@
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/local_sync.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
@@ -11,6 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.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/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
@@ -46,6 +49,15 @@ 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(
(ref) => HashService(
localAlbumRepository: ref.watch(localAlbumRepository),
@@ -53,5 +65,7 @@ final hashServiceProvider = Provider(
nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
cancellation: ref.watch(cancellationProvider),
stackRepository: ref.watch(driftStackProvider),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
),
);
@@ -53,9 +53,18 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
);
final List<dynamic> _batchedAssetUploadReady = [];
// Batches a burst of stack updates (one per uploaded edit) into a single
// remote sync. Kept separate from _batchDebouncer so the two don't overwrite
// each other's pending action.
final Debouncer _stackUpdateDebouncer = Debouncer(
interval: const Duration(seconds: 2),
maxWaitTime: const Duration(seconds: 5),
);
@override
void dispose() {
_batchDebouncer.dispose();
_stackUpdateDebouncer.dispose();
super.dispose();
}
@@ -105,6 +114,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
} catch (e) {
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
@@ -188,6 +198,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
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. Debounced so a
// backup of many edits doesn't trigger a sync per event.
void _handleAssetStackUpdate(dynamic _) {
_stackUpdateDebouncer.run(() => _ref.read(backgroundSyncProvider).runFreshRemoteSync());
}
void _processBatchedAssetUploadReadyV1() {
if (_batchedAssetUploadReady.isEmpty) {
return;
@@ -75,6 +75,10 @@ class AssetApiRepository extends ApiRepository {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: Optional.present(primaryAssetId)));
}
Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
@@ -30,6 +30,11 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupEditPairGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
@@ -62,6 +67,11 @@ class UploadRepository {
return FileDownloader().allTasks(group: group);
}
/// The ENQUEUED or RUNNING task with this id, if any.
Future<Task?> getTaskById(String taskId) {
return FileDownloader().taskForId(taskId);
}
Future<void> start() {
return FileDownloader().start();
}
@@ -6,20 +6,27 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/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/extensions/platform_extensions.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/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.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/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/sync.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/cloud_metadata.dart';
import 'package:immich_mobile/services/edit_pair.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
@@ -31,41 +38,85 @@ final backgroundUploadServiceProvider = Provider((ref) {
ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(editRevertServiceProvider),
ref.watch(driftStackProvider),
);
ref.onDispose(service.dispose);
return service;
});
/// Which hop of an iOS edit chain a background task is, so its completion knows
/// what to enqueue next. A live-photo edit runs all four hops; a plain photo edit
/// is the two-still chain baseStill -> editStill. none = a normal upload.
enum LiveEditPhase { none, baseVideo, baseStill, editVideo, editStill }
/// Metadata for upload tasks to track live photo handling
class UploadTaskMetadata {
final String localAssetId;
// Legacy live-photo auto-chain trigger (video upload -> enqueue its still), not
// a media-type flag; edit-chain hops keep it false. livePhotoVideoId is no
// longer written but stays so persisted task metadata keeps decoding.
final bool isLivePhotos;
final String livePhotoVideoId;
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
// Path of the temp/cache file backing this task, so it can be cleaned up on a
// terminal status.
final String basePath;
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
return UploadTaskMetadata(
localAssetId: localAssetId ?? this.localAssetId,
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
);
}
// Edit chain state: which hop this is, the base still temp path (carried by the
// base video so its completion can enqueue the base still), the base still remote
// id threaded to the edit still as its stackParentId, and the local checksum at
// task-build time so a re-edit racing this upload can't be marked synced.
final LiveEditPhase liveEditPhase;
final String baseStillPath;
final String pendingStackParentId;
final String? checksum;
// The dead prior a rebuild chain is replacing (prior pointed at a hard-deleted
// remote at plan time). Lets the base junctions tell a rebuild in progress
// (row prior still == this) from a replayed completion (prior re-stamped).
final String stalePriorId;
const UploadTaskMetadata({
required this.localAssetId,
this.isLivePhotos = false,
this.livePhotoVideoId = '',
this.basePath = '',
this.liveEditPhase = LiveEditPhase.none,
this.baseStillPath = '',
this.pendingStackParentId = '',
this.checksum,
this.stalePriorId = '',
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'localAssetId': localAssetId,
'isLivePhotos': isLivePhotos,
'livePhotoVideoId': livePhotoVideoId,
'basePath': basePath,
'liveEditPhase': liveEditPhase.name,
'baseStillPath': baseStillPath,
'pendingStackParentId': pendingStackParentId,
'checksum': checksum,
'stalePriorId': stalePriorId,
};
}
factory UploadTaskMetadata.fromMap(Map<String, dynamic> map) {
return UploadTaskMetadata(
localAssetId: map['localAssetId'] as String,
isLivePhotos: map['isLivePhotos'] as bool,
livePhotoVideoId: map['livePhotoVideoId'] as String,
isLivePhotos: (map['isLivePhotos'] as bool?) ?? false,
livePhotoVideoId: (map['livePhotoVideoId'] as String?) ?? '',
basePath: (map['basePath'] as String?) ?? '',
liveEditPhase: LiveEditPhase.values.asNameMap()[map['liveEditPhase'] as String?] ?? LiveEditPhase.none,
baseStillPath: (map['baseStillPath'] as String?) ?? '',
pendingStackParentId: (map['pendingStackParentId'] as String?) ?? '',
checksum: map['checksum'] as String?,
stalePriorId: (map['stalePriorId'] as String?) ?? '',
);
}
@@ -76,7 +127,7 @@ class UploadTaskMetadata {
@override
String toString() =>
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, basePath: $basePath, liveEditPhase: $liveEditPhase, baseStillPath: $baseStillPath, pendingStackParentId: $pendingStackParentId, checksum: $checksum, stalePriorId: $stalePriorId)';
@override
bool operator ==(covariant UploadTaskMetadata other) {
@@ -86,11 +137,26 @@ class UploadTaskMetadata {
return other.localAssetId == localAssetId &&
other.isLivePhotos == isLivePhotos &&
other.livePhotoVideoId == livePhotoVideoId;
other.livePhotoVideoId == livePhotoVideoId &&
other.basePath == basePath &&
other.liveEditPhase == liveEditPhase &&
other.baseStillPath == baseStillPath &&
other.pendingStackParentId == pendingStackParentId &&
other.checksum == checksum &&
other.stalePriorId == stalePriorId;
}
@override
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
int get hashCode =>
localAssetId.hashCode ^
isLivePhotos.hashCode ^
livePhotoVideoId.hashCode ^
basePath.hashCode ^
liveEditPhase.hashCode ^
baseStillPath.hashCode ^
pendingStackParentId.hashCode ^
checksum.hashCode ^
stalePriorId.hashCode;
}
/// Service for handling background uploads using iOS URLSession (background_downloader)
@@ -104,6 +170,9 @@ class BackgroundUploadService {
this._localAssetRepository,
this._backupRepository,
this._assetMediaRepository,
this._nativeSyncApi,
this._editRevertService,
this._stackRepository,
) {
_uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
@@ -114,6 +183,9 @@ class BackgroundUploadService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository;
final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final EditRevertService _editRevertService;
final DriftStackRepository _stackRepository;
final Logger _logger = Logger('BackgroundUploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
@@ -152,12 +224,27 @@ class BackgroundUploadService {
return _uploadRepository.getActiveTasks(group);
}
/// Active tasks across every backup group. The resume gate needs this so a chain
/// stalled mid-flight in the live/edit groups (with the normal group already empty)
/// resumes instead of kicking off a duplicate cycle.
Future<int> getActiveBackupTaskCount() async {
final counts = await Future.wait([
_uploadRepository.getActiveTasks(kBackupGroup),
_uploadRepository.getActiveTasks(kBackupEditPairGroup),
_uploadRepository.getActiveTasks(kBackupLivePhotoGroup),
]);
return counts.fold<int>(0, (sum, tasks) => sum + tasks.length);
}
/// Start background upload using iOS URLSession
///
/// Finds backup candidates, builds upload tasks, and enqueues them
/// for background processing.
Future<void> uploadBackupCandidates(String userId) async {
await _storageRepository.clearCache();
// Safe here: the caller only starts a fresh cycle when no tasks are active in
// any backup group, so no pending chain still references these base temps.
await _storageRepository.clearEditBaseCache();
shouldAbortQueuingTasks = false;
final candidates = await _backupRepository.getCandidates(userId);
@@ -168,12 +255,17 @@ class BackgroundUploadService {
_logger.info("Found ${candidates.length} backup candidates for background tasks");
final ownerId = Store.tryGet(StoreKey.currentUser)?.id;
const batchSize = 100;
final batch = candidates.take(batchSize).toList();
List<UploadTask> tasks = [];
for (final asset in batch) {
final task = await getUploadTask(asset);
// Walk the full candidate list until a batch is filled: deferred or
// unbuildable assets return no task and must not starve what's behind them.
for (final asset in candidates) {
if (tasks.length >= batchSize || shouldAbortQueuingTasks) {
break;
}
final task = await getUploadTask(asset, ownerId: ownerId);
if (task != null) {
tasks.add(task);
}
@@ -192,11 +284,15 @@ class BackgroundUploadService {
shouldAbortQueuingTasks = true;
await _storageRepository.clearCache();
await _storageRepository.clearEditBaseCache();
await _uploadRepository.reset(kBackupGroup);
await _uploadRepository.reset(kBackupEditPairGroup);
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
await _uploadRepository.deleteDatabaseRecords(kBackupEditPairGroup);
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
return activeTasks.length;
final activeEditTasks = await _uploadRepository.getActiveTasks(kBackupEditPairGroup);
return activeTasks.length + activeEditTasks.length;
}
/// Resume background backup processing
@@ -205,9 +301,20 @@ class BackgroundUploadService {
}
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) {
case TaskStatus.complete:
unawaited(_handleLivePhoto(update));
unawaited(_handleLivePhoto(update, metadata));
unawaited(handleLiveEditChain(update, metadata));
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
if (CurrentPlatform.isIOS) {
try {
@@ -220,33 +327,55 @@ class BackgroundUploadService {
break;
case TaskStatus.failed:
case TaskStatus.canceled:
case TaskStatus.notFound:
unawaited(_cleanupTempResourceOnFailure(metadata));
unawaited(_clearDeadPriorOnStack400(update, metadata));
break;
default:
break;
}
}
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
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) {
final remoteId = _remoteIdFromResponse(update);
if (remoteId == null) {
return;
}
if (update.responseBody == null || update.responseBody!.isEmpty) {
return;
}
final response = jsonDecode(update.responseBody!);
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
final uploadTask = await getLivePhotoUploadTask(localAsset, response['id'] as String);
// Edited since the video task was built (redelivered completion): uploading
// the still now would ship the edit standalone and stamp it synced. Drop —
// the asset is a candidate and the edit chain handles it. The row itself
// can be stale in the same window (local sync hasn't seen the edit yet),
// so also confirm with the offline adjustment read; un-edited photos have
// no adjustment plist, making this a cheap resources lookup.
if (metadata.checksum != null && metadata.checksum != localAsset.checksum) {
return;
}
try {
final state = await _nativeSyncApi
.getEditState(localAsset.id, allowNetworkAccess: false)
.timeout(const Duration(seconds: 30));
if (state == EditState.edited) {
return;
}
} catch (_) {
// No positive edit evidence; proceed like before.
}
final uploadTask = await getLivePhotoUploadTask(localAsset, remoteId);
if (uploadTask == null) {
return;
@@ -258,14 +387,477 @@ class BackgroundUploadService {
}
}
/// Advances the edit chain on each hop's completion. Live photo:
/// base video → base still → edit video → edit still; plain photo:
/// base still → edit still. The base still completion stamps priorRemoteId so
/// an app kill mid-chain resumes onto the already-uploaded original (via
/// AbsorbIntoPrior) instead of re-uploading it; the base junctions skip once
/// the stamp advances past what the chain knew (stalePriorId tells a rebuild
/// over a dead prior apart from a replay) and every enqueue is skipped while a
/// task with the same id is already active, so a replayed completion can't
/// fork a second chain. Completions redelivered on a later launch after the
/// chain has finished (syncedChecksum already matches) are dropped outright,
/// and the edit hops are dropped when the photo was re-edited or reverted
/// while the chain was in flight.
@visibleForTesting
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
Future<void> handleLiveEditChain(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || metadata.liveEditPhase == LiveEditPhase.none) {
return;
}
final remoteId = _remoteIdFromResponse(update);
if (remoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
// Finished-chain replays drop here. Rebuild AND absorb chains can start
// while the row still carries the previous terminal stamps (synced ==
// checksum) — those hops carry the dead prior as stalePriorId, so the
// drop only fires once the prior advanced past it (chain re-stamped and
// finished). Plain chains carry no marker and drop as before.
final hasFinishedStamps = localAsset.checksum != null && localAsset.syncedChecksum == localAsset.checksum;
if (hasFinishedStamps && (metadata.stalePriorId.isEmpty || localAsset.priorRemoteId != metadata.stalePriorId)) {
return;
}
switch (metadata.liveEditPhase) {
case LiveEditPhase.baseVideo:
if (_priorAdvanced(localAsset, metadata) || await _hasActiveTask('${localAsset.id}_bs')) {
return;
}
final next = await _buildBaseStillTask(
localAsset,
metadata.baseStillPath,
baseVideoId: remoteId,
stalePriorId: metadata.stalePriorId,
);
if (!await _enqueueChainTask(next)) {
await _deleteTempFile(metadata.baseStillPath);
}
case LiveEditPhase.baseStill:
if (_priorAdvanced(localAsset, metadata)) {
return;
}
await _localAssetRepository.markSynced(metadata.localAssetId, priorRemoteId: remoteId, syncedChecksum: null);
if (await _editDriftedMidChain(localAsset, metadata)) {
return;
}
final next = await _buildEditTask(localAsset, stackParentId: remoteId);
if (next != null) {
await _enqueueChainTask(next);
}
case LiveEditPhase.editVideo:
if (await _hasActiveTask(localAsset.id)) {
return;
}
if (await _editDriftedMidChain(localAsset, metadata)) {
return;
}
final next = await _buildEditStillTask(
localAsset,
editVideoId: remoteId,
stackParentId: metadata.pendingStackParentId,
stalePriorId: metadata.stalePriorId,
);
if (next != null) {
await _enqueueChainTask(next);
}
case LiveEditPhase.editStill:
await _localAssetRepository.markSynced(
metadata.localAssetId,
priorRemoteId: remoteId,
syncedChecksum: metadata.checksum ?? localAsset.checksum,
);
case LiveEditPhase.none:
break;
}
} catch (error, stackTrace) {
dPrint(() => "Error handling live edit chain: $error $stackTrace");
}
}
/// The next hop after the base still: the edit video for a live photo (so the
/// edit keeps its motion), the edit still for a plain photo.
Future<UploadTask?> _buildEditTask(LocalAsset asset, {required String stackParentId}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
return entity.isLivePhoto
? _buildEditVideoTask(asset, stackParentId: stackParentId)
: _buildEditStillTask(asset, editVideoId: null, stackParentId: stackParentId);
}
Future<bool> _hasActiveTask(String taskId) async {
try {
return await _uploadRepository.getTaskById(taskId) != null;
} catch (_) {
return false;
}
}
/// The row's prior moved past what this chain knew: stamped by a normal chain
/// (no stale prior) or re-stamped past the dead prior a rebuild was replacing.
/// Either way this completion is a replay, not a live junction.
bool _priorAdvanced(LocalAsset asset, UploadTaskMetadata metadata) {
final prior = asset.priorRemoteId;
if (prior == null) {
return false;
}
return metadata.stalePriorId.isEmpty || prior != metadata.stalePriorId;
}
/// The photo changed under a mid-flight chain: re-edited (checksum moved since
/// the hop was built) or reverted (positive notEdited probe, cheap offline
/// read). The chain drops its edit hops — the asset is still a candidate and
/// re-plans fresh next cycle; the uploaded base stays stamped for resume.
Future<bool> _editDriftedMidChain(LocalAsset asset, UploadTaskMetadata metadata) async {
if (metadata.checksum != null && metadata.checksum != asset.checksum) {
return true;
}
try {
final state = await _nativeSyncApi
.getEditState(asset.id, allowNetworkAccess: false)
.timeout(const Duration(seconds: 30));
return state == EditState.notEdited;
} catch (_) {
// No positive revert evidence; let the chain finish.
return false;
}
}
Future<bool> _enqueueChainTask(UploadTask task) async {
if (shouldAbortQueuingTasks) {
return false;
}
try {
final results = await enqueueTasks([task]);
if (results.every((enqueued) => enqueued)) {
return true;
}
} catch (error, stack) {
_logger.warning(() => "Failed to enqueue chain task ${task.taskId}", error, stack);
return false;
}
_logger.warning(() => "Failed to enqueue chain task ${task.taskId}");
return false;
}
Future<void> _deleteTempFile(String path) async {
if (path.isEmpty) {
return;
}
try {
await File(path).delete();
} catch (_) {}
}
/// Saves the uploaded remote id as the asset's priorRemoteId so a later edit
/// stacks onto it. Edit-chain hops are skipped here; the chain router stamps them.
@visibleForTesting
Future<void> recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
// Edit stacking is iOS-only; don't stamp prior/synced state on Android.
if (!CurrentPlatform.isIOS ||
metadata == null ||
metadata.isLivePhotos ||
metadata.liveEditPhase != LiveEditPhase.none ||
metadata.localAssetId.isEmpty) {
return;
}
final remoteId = _remoteIdFromResponse(update);
if (remoteId == null) {
return;
}
// metadata.checksum is what actually uploaded (captured at build time). A
// legacy task without it must NOT fall back to the current row checksum:
// the photo may have been edited while the task sat in the queue, and
// stamping the new checksum would suppress that edit forever. null keeps
// the asset re-resolvable.
await _localAssetRepository.markSynced(
metadata.localAssetId,
priorRemoteId: remoteId,
syncedChecksum: metadata.checksum,
);
} catch (error, stackTrace) {
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
}
}
@visibleForTesting
Future<void> cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) => _cleanupTempResourceOnFailure(metadata);
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
if (metadata == null) {
return;
}
// basePath = the failed hop's own temp; baseStillPath = the base still a live-edit
// base video carries forward (leaks otherwise when the chain aborts at hop one).
for (final path in [metadata.basePath, metadata.baseStillPath]) {
await _deleteTempFile(path);
}
}
/// A failed hop naming a dead stack parent means the stamped prior no longer
/// exists server-side; clear the stamps so the next cycle re-resolves fresh
/// instead of looping on the same dead id.
Future<void> _clearDeadPriorOnStack400(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || metadata.localAssetId.isEmpty) {
return;
}
final serverMessage = '${update.responseBody ?? ''} ${update.exception?.description ?? ''}';
if (!serverMessage.contains(kDeadStackParentError)) {
return;
}
await _localAssetRepository.clearSyncStamps(metadata.localAssetId);
} catch (error, stackTrace) {
dPrint(() => "Error clearing dead prior: $error $stackTrace");
}
}
/// 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;
}
}
/// Entry of the live-photo edit chain. With an original video, upload it first so
/// the base still can link to it; without one (the edit turned Live off) the base
/// degrades to a still-only stack parent.
Future<UploadTask?> _buildLiveEntryTask(
LocalAsset asset,
BaseResource still,
BaseResource? video, {
required String stalePriorId,
}) {
if (video != null) {
return _buildBaseVideoTask(asset, still.path, video.path, stalePriorId: stalePriorId);
}
return _buildBaseStillTask(asset, still.path, baseVideoId: null, chainEntry: true, stalePriorId: stalePriorId);
}
Future<UploadTask> _buildBaseVideoTask(
LocalAsset asset,
String baseStillPath,
String baseVideoPath, {
String stalePriorId = '',
}) {
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
liveEditPhase: LiveEditPhase.baseVideo,
basePath: baseVideoPath,
baseStillPath: baseStillPath,
checksum: asset.checksum,
stalePriorId: stalePriorId,
).toJson();
return buildUploadTask(
File(baseVideoPath),
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: p.setExtension(asset.name, p.extension(baseVideoPath)),
deviceAssetId: '${asset.id}_bv',
metadata: metadata,
fields: {'visibility': kHiddenVisibility},
group: kBackupGroup,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
);
}
/// [chainEntry] = this base still starts the chain (plain-photo edit, or a live
/// edit with no recoverable original video), so it queues like any new upload;
/// a continuation hop runs at top priority to finish the started chain first.
Future<UploadTask> _buildBaseStillTask(
LocalAsset asset,
String baseStillPath, {
required String? baseVideoId,
bool chainEntry = false,
String stalePriorId = '',
}) {
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
liveEditPhase: LiveEditPhase.baseStill,
basePath: baseStillPath,
checksum: asset.checksum,
stalePriorId: stalePriorId,
).toJson();
return buildUploadTask(
File(baseStillPath),
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: p.setExtension(asset.name, p.extension(baseStillPath)),
deviceAssetId: '${asset.id}_bs',
metadata: metadata,
fields: baseVideoId != null ? {'livePhotoVideoId': baseVideoId} : null,
group: chainEntry ? kBackupGroup : kBackupEditPairGroup,
priority: chainEntry ? null : 0,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
// base = the unedited original, so cloudId but no adjustmentTime
cloudId: asset.cloudId,
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
Future<UploadTask?> _buildEditVideoTask(
LocalAsset asset, {
required String stackParentId,
String stalePriorId = '',
}) async {
final motion = await _storageRepository.getMotionFileForAsset(asset);
if (motion == null) {
_logger.warning("Failed to get motion file for live edit ${asset.id} - ${asset.name}");
return null;
}
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
liveEditPhase: LiveEditPhase.editVideo,
basePath: motion.path,
pendingStackParentId: stackParentId,
checksum: asset.checksum,
stalePriorId: stalePriorId,
).toJson();
return buildUploadTask(
motion,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: p.setExtension(originalFileName, p.extension(motion.path)),
deviceAssetId: '${asset.id}_ev',
metadata: metadata,
fields: {'visibility': kHiddenVisibility},
group: kBackupGroup,
priority: 0,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
);
}
/// The terminal hop: the edited still, linked to its own motion ([editVideoId],
/// live photos only) and stacked onto the base still ([stackParentId]).
Future<UploadTask?> _buildEditStillTask(
LocalAsset asset, {
required String? editVideoId,
required String stackParentId,
String stalePriorId = '',
}) async {
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
_logger.warning("Failed to get still file for live edit ${asset.id} - ${asset.name}");
return null;
}
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
liveEditPhase: LiveEditPhase.editStill,
basePath: file.path,
checksum: asset.checksum,
stalePriorId: stalePriorId,
).toJson();
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
fields: {
if (editVideoId != null) 'livePhotoVideoId': editVideoId,
if (stackParentId.isNotEmpty) 'stackParentId': stackParentId,
},
group: kBackupEditPairGroup,
priority: 0,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
// edit = WITH adjustmentTime so the server keeps the edit timestamp
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getUploadTask(
LocalAsset asset, {
String group = kBackupGroup,
int? priority,
String? ownerId,
}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
_logger.warning("Asset entity not found for ${asset.id} - ${asset.name}");
return null;
}
// iOS edit pair: stack a user edit onto its original. resolveEditPair reads the edit
// state and decides whether to reuse a prior upload or upload the original first.
if (CurrentPlatform.isIOS) {
// A reverted edit flips the stack back to the original and skips the upload.
if (asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset) != null) {
return null;
}
final plan = await resolveEditPair(
_nativeSyncApi,
asset,
stackRepository: _stackRepository,
ownerId: ownerId ?? Store.tryGet(StoreKey.currentUser)?.id,
log: _logger,
isLivePhoto: entity.isLivePhoto,
);
switch (plan) {
case UploadBaseFirst(:final base):
return _buildBaseStillTask(
asset,
base.path,
baseVideoId: null,
chainEntry: true,
stalePriorId: asset.priorRemoteId ?? '',
);
case UploadBaseLivePhotoFirst(:final still, :final video):
return _buildLiveEntryTask(asset, still, video, stalePriorId: asset.priorRemoteId ?? '');
case AbsorbIntoPrior(:final parentId):
// Re-editing an already-stacked live photo uploads its new video then still so
// the edit keeps its motion; a plain photo just stacks the still. The current
// prior rides along as stalePriorId: an absorb can start while the row still
// carries finished-chain stamps (prior hard-deleted, base re-found by checksum),
// and the junctions must not mistake its hops for finished-chain replays.
return entity.isLivePhoto
? _buildEditVideoTask(asset, stackParentId: parentId, stalePriorId: asset.priorRemoteId ?? '')
: _buildEditStillTask(
asset,
editVideoId: null,
stackParentId: parentId,
stalePriorId: asset.priorRemoteId ?? '',
);
case NoEditPair():
break;
case DeferEditPair():
// Undecidable right now (prior in server trash, or the original
// couldn't be read). The asset stays a candidate; retry next cycle.
_logger.fine(() => "Deferring upload for ${asset.id} - ${asset.name}");
return null;
}
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
@@ -300,7 +892,7 @@ class BackgroundUploadService {
String metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: entity.isLivePhoto,
livePhotoVideoId: '',
checksum: asset.checksum,
).toJson();
final requiresWiFi = _shouldRequireWiFi(asset);
@@ -312,6 +904,9 @@ class BackgroundUploadService {
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
// for a live photo this is the motion video — upload it hidden so it never
// flashes onto the timeline before its still (a later fire) links it.
fields: entity.isLivePhoto ? {'visibility': kHiddenVisibility} : null,
group: group,
priority: priority,
isFavorite: asset.isFavorite,
@@ -346,6 +941,9 @@ class BackgroundUploadService {
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
// so recordPriorRemoteIdOnSuccess stamps the still's remote id and a later
// edit absorbs onto it instead of rebuilding the whole base pair.
metadata: UploadTaskMetadata(localAssetId: asset.id, checksum: asset.checksum).toJson(),
fields: fields,
group: kBackupLivePhotoGroup,
priority: 0, // Highest priority to get upload immediately
@@ -391,6 +989,13 @@ class BackgroundUploadService {
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final cloudMetadata = cloudMetadataJson(
cloudId: cloudId,
createdAt: createdAt,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
);
final fieldsMap = {
'filename': originalFileName ?? filename,
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
@@ -401,19 +1006,7 @@ class BackgroundUploadService {
'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0',
if (fields != null) ...fields,
if (CurrentPlatform.isIOS && cloudId != null)
'metadata': jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: cloudId,
createdAt: createdAt.toIso8601String(),
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
),
]),
if (cloudMetadata != null) 'metadata': cloudMetadata,
};
return UploadTask(
+32
View File
@@ -0,0 +1,32 @@
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
/// The iOS mobile-app metadata multipart field, shared by the foreground and
/// background upload paths so the payload only has one definition. null when
/// there's nothing to attach. Pass [adjustmentTime] only for an edited render;
/// the unedited base carries none.
String? cloudMetadataJson({
required String? cloudId,
required DateTime createdAt,
String? adjustmentTime,
String? latitude,
String? longitude,
}) {
if (!CurrentPlatform.isIOS || cloudId == null) {
return null;
}
return jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: cloudId,
createdAt: createdAt.toIso8601String(),
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
),
]);
}
+236
View File
@@ -0,0 +1,236 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.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: positively not edited, identical bytes, or the
/// original resource simply isn't there to recover.
class NoEditPair extends EditPairPlan {
const NoEditPair();
}
/// Can't be decided right now: the prior upload sits in the server trash, or the
/// adjustment metadata / original couldn't be read (offloaded to iCloud, network
/// off, stalled read). Skip the asset this cycle — it stays a candidate and
/// resolves once conditions change. Uploading anyway would mark the edit synced
/// and permanently drop the original from backup.
class DeferEditPair extends EditPairPlan {
const DeferEditPair();
}
/// 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);
}
/// Live photo edit: upload the original pair first (the [still] always, the [video]
/// when one survives) and stack the edited live photo onto the original still.
/// [video] is null when the original has no paired video to recover (e.g. the edit
/// turned Live off), which degrades to a still-only parent.
class UploadBaseLivePhotoFirst extends EditPairPlan {
final BaseResource still;
final BaseResource? video;
const UploadBaseLivePhotoFirst(this.still, this.video);
}
/// Works out how an edited photo should stack: reuse a prior upload, upload the
/// original first, do nothing, or defer to a later cycle. Shared by the foreground
/// and background upload paths. The caller already checked it's iOS; pass
/// [isLivePhoto] for a live photo so the original pair (still + paired video) is
/// read instead of a single still.
///
/// A photo that was never edited only carries the capture-time Photographic Style,
/// which iOS stamps at [LocalAsset.createdAt]; a real edit moves [LocalAsset.adjustmentTime]
/// later. When they match (or there's no adjustment at all) there's nothing to stack, so
/// we skip the native read. Anything that moved the timestamp (edit, retime, revert) falls
/// through to [NativeSyncApi.getBaseResource] / [NativeSyncApi.getBaseLivePhoto], which read
/// the adjustment plist and decide.
Future<EditPairPlan> resolveEditPair(
NativeSyncApi nativeSyncApi,
LocalAsset asset, {
required DriftStackRepository stackRepository,
required String? ownerId,
Logger? log,
bool isLivePhoto = false,
}) async {
final priorRemoteId = asset.priorRemoteId;
if (priorRemoteId != null) {
PriorState priorState;
try {
priorState = await stackRepository.priorState(priorRemoteId);
} catch (error, stack) {
log?.warning(() => "Failed to check prior remote $priorRemoteId for ${asset.id}", error, stack);
return const DeferEditPair();
}
switch (priorState) {
case PriorState.live:
return AbsorbIntoPrior(priorRemoteId);
case PriorState.trashed:
// The prior sits in the server trash. Re-uploading the base would just
// dedupe onto the trashed row and the edit would 400 stacking onto it
// ("Cannot stack onto a trashed or missing asset"), so wait: restore
// makes it live (absorb), emptying trash makes it missing (rebuild).
return const DeferEditPair();
case PriorState.missing:
// No synced row for the stamp. With syncedChecksum unset a chain is
// mid-flight and the row just hasn't synced back yet — resume onto it.
// With syncedChecksum set the completed prior has since vanished from
// the server (hard delete), so fall through and re-resolve from scratch.
if (asset.syncedChecksum == null) {
return AbsorbIntoPrior(priorRemoteId);
}
}
}
if (!_mightBeEdited(asset)) {
return const NoEditPair();
}
if (isLivePhoto) {
return _resolveLivePair(nativeSyncApi, asset, stackRepository: stackRepository, ownerId: ownerId, log: log);
}
BaseResource? base;
try {
// Native bounds each resource read (classify + still) at 120s idle; the outer
// timeout only catches a reply that never comes back across the platform channel.
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true).timeout(_baseReadTimeout);
} catch (error, stack) {
// Transient (timeout, unreadable plist, iCloud hiccup): defer instead of
// uploading the edit standalone, which would permanently skip the original.
log?.warning(() => "Failed to read base resource for ${asset.id}, deferring", error, stack);
return const DeferEditPair();
}
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) {
await _deleteTemp(base.path);
return const NoEditPair();
}
switch (await _planForExistingBase(stackRepository, base.sha1, ownerId, log: log)) {
case AbsorbIntoPrior(:final parentId):
await _deleteTemp(base.path);
return AbsorbIntoPrior(parentId);
case DeferEditPair():
await _deleteTemp(base.path);
return const DeferEditPair();
default:
return UploadBaseFirst(base);
}
}
/// The base bytes may already be on the server: backed up before the stamps
/// existed, by another install, or after the stamps were belt-cleared. Absorb
/// straight onto a live copy instead of re-uploading bytes the server has;
/// defer while that copy sits in the trash — uploading would just dedupe onto
/// the trashed row and the stack would 400. null = no copy, upload the base.
Future<EditPairPlan?> _planForExistingBase(
DriftStackRepository stackRepository,
String baseSha1,
String? ownerId, {
Logger? log,
}) async {
if (ownerId == null) {
return null;
}
try {
final dup = await stackRepository.remoteByChecksum(baseSha1, ownerId);
return switch (dup.state) {
PriorState.live => AbsorbIntoPrior(dup.remoteId!),
PriorState.trashed => const DeferEditPair(),
PriorState.missing => null,
};
} catch (error, stack) {
log?.warning(() => "Failed to check base checksum against synced remotes", error, stack);
return const DeferEditPair();
}
}
/// Reads the original pair of an edited live photo. Skips stacking when the original
/// still matches the current bytes (e.g. a video-only trim) — the base still would
/// dedupe to the edit itself on the server, so it can't be its own stack parent; the
/// edit just uploads normally. Temps are dropped on every non-stack outcome.
Future<EditPairPlan> _resolveLivePair(
NativeSyncApi nativeSyncApi,
LocalAsset asset, {
required DriftStackRepository stackRepository,
required String? ownerId,
Logger? log,
}) async {
BaseLivePhoto? live;
try {
// Up to three native reads here (classify + still + paired video), 120s idle each.
live = await nativeSyncApi.getBaseLivePhoto(asset.id, allowNetworkAccess: true).timeout(_baseLiveReadTimeout);
} catch (error, stack) {
log?.warning(() => "Failed to read base live photo for ${asset.id}, deferring", error, stack);
return const DeferEditPair();
}
if (live == null) {
return const NoEditPair();
}
if (live.still.sha1 == asset.checksum) {
await _deleteTemp(live.still.path);
await _deleteTemp(live.video?.path);
return const NoEditPair();
}
switch (await _planForExistingBase(stackRepository, live.still.sha1, ownerId, log: log)) {
case AbsorbIntoPrior(:final parentId):
await _deleteTemp(live.still.path);
await _deleteTemp(live.video?.path);
return AbsorbIntoPrior(parentId);
case DeferEditPair():
await _deleteTemp(live.still.path);
await _deleteTemp(live.video?.path);
return const DeferEditPair();
default:
return UploadBaseLivePhotoFirst(live.still, live.video);
}
}
Future<void> _deleteTemp(String? path) async {
if (path == null) {
return;
}
try {
await File(path).delete();
} catch (_) {}
}
/// iOS stamps the capture-time Photographic Style at the creation time and moves the
/// adjustment timestamp on any later change. A gap past a small tolerance (capture jitter
/// is sub-second, real edits are seconds apart) is worth a native check; no adjustment at
/// all means the photo was never touched.
bool _mightBeEdited(LocalAsset asset) {
final adjustedAt = asset.adjustmentTime;
if (adjustedAt == null) {
return false;
}
return adjustedAt.difference(asset.createdAt).inSeconds.abs() > _editTimestampToleranceSeconds;
}
const _editTimestampToleranceSeconds = 2;
// Generous on purpose: the native idle watchdog (120s without a chunk) owns
// stall detection, so these only catch a reply lost on the platform channel —
// a tight bound here would kill big-but-flowing iCloud downloads.
const _baseReadTimeout = Duration(minutes: 30);
const _baseLiveReadTimeout = Duration(minutes: 45);
@@ -1,23 +1,31 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.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/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/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/platform_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/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.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/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/stack.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/upload.repository.dart';
import 'package:immich_mobile/services/cloud_metadata.dart';
import 'package:immich_mobile/services/edit_pair.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -39,6 +47,10 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(localAssetRepository),
ref.watch(editRevertServiceProvider),
ref.watch(driftStackProvider),
);
});
@@ -54,6 +66,10 @@ class ForegroundUploadService {
this._backupRepository,
this._connectivityApi,
this._assetMediaRepository,
this._nativeSyncApi,
this._localAssetRepository,
this._editRevertService,
this._stackRepository,
);
final UploadRepository _uploadRepository;
@@ -61,6 +77,10 @@ class ForegroundUploadService {
final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi;
final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final DriftLocalAssetRepository _localAssetRepository;
final EditRevertService _editRevertService;
final DriftStackRepository _stackRepository;
final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false;
@@ -142,7 +162,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks, surfaceDefers: true),
);
}
@@ -236,6 +256,7 @@ class ForegroundUploadService {
LocalAsset asset,
Completer<void>? cancelToken, {
required UploadCallbacks callbacks,
bool surfaceDefers = false,
}) async {
File? file;
File? livePhotoFile;
@@ -250,6 +271,64 @@ class ForegroundUploadService {
return;
}
// A reverted iOS edit flips the stack back to the original and skips the upload.
// Works for live photos too — getEditState reads the adjustment plist, which is
// media-agnostic. Report the flipped-to base, not the pre-flip prior (the edit
// being reverted away) — album-add consumers link whatever id this reports.
if (CurrentPlatform.isIOS && asset.priorRemoteId != null) {
final revertedTo = await _editRevertService.tryHandleRevert(asset);
if (revertedTo != null) {
callbacks.onSuccess?.call(asset.localId!, revertedTo);
return;
}
}
final deviceId = Store.get(StoreKey.deviceId);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': asset.localId!,
'deviceId': deviceId,
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(),
'duration': (asset.durationMs ?? 0).toString(),
};
// Edit pair: upload the unedited original first and stack the edit onto it. For a
// live photo that's the original still+video pair; this upload stays the edit and
// its own edited motion uploads after, below. Resolved before anything is
// materialized so a deferred or failed pair doesn't burn an iCloud download or a
// motion upload every retry cycle, and before the edit's metadata is added so the
// base isn't stamped with the edit's adjustmentTime.
final base = await _resolveStackParent(asset, Map.of(fields), cancelToken, isLivePhoto: entity.isLivePhoto);
if (base.deferred) {
// Undecidable right now (prior in server trash, or the original couldn't be
// read). The asset stays a candidate; auto backup retries silently, a manual
// upload tells the user why nothing happened.
_logger.fine(() => "Deferring upload for ${asset.localId}: edit pair undecidable this cycle");
if (surfaceDefers) {
callbacks.onError?.call(asset.localId!, "upload_deferred_edit_pair".t());
}
return;
}
if (base.baseFailed) {
// The original couldn't be uploaded. Don't upload the edit on its own and mark
// it synced — that would permanently drop the original from backup. Leave the
// whole pair as a candidate to retry next cycle.
_logger.warning(() => "Base upload failed for ${asset.localId}, retrying the pair later");
if (base.isCancelled) {
shouldAbortUpload = true;
return;
}
if (base.errorMessage != null) {
callbacks.onError?.call(asset.localId!, base.errorMessage!);
if (base.errorMessage == _kQuotaError) {
shouldAbortUpload = true;
}
}
return;
}
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
@@ -317,19 +396,13 @@ class ForegroundUploadService {
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId);
final fields = {
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
'deviceAssetId': asset.localId!,
'deviceId': deviceId,
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(),
'duration': (asset.durationMs ?? 0).toString(),
};
if (base.stackParentId != null) {
fields['stackParentId'] = base.stackParentId!;
}
// Upload live photo video first if available
// The edit's own motion video, uploaded hidden so it never flashes onto the
// timeline before the still below links it.
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
@@ -338,7 +411,7 @@ class ForegroundUploadService {
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
fields: fields,
fields: {...fields, 'visibility': kHiddenVisibility}..remove('stackParentId'),
cancelToken: cancelToken,
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
@@ -348,6 +421,13 @@ class ForegroundUploadService {
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
livePhotoVideoId = livePhotoResult.remoteAssetId;
} else if (livePhotoResult.isCancelled) {
shouldAbortUpload = true;
return;
} else if (livePhotoResult.errorMessage == _kQuotaError) {
callbacks.onError?.call(asset.localId!, livePhotoResult.errorMessage!);
shouldAbortUpload = true;
return;
}
}
@@ -356,19 +436,9 @@ class ForegroundUploadService {
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: asset.cloudId,
createdAt: asset.createdAt.toIso8601String(),
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
),
),
]);
final metadata = _cloudMetadata(asset, includeAdjustment: true);
if (metadata != null) {
fields['metadata'] = metadata;
}
final onProgress = callbacks.onProgress;
@@ -384,6 +454,18 @@ class ForegroundUploadService {
);
if (result.isSuccess && result.remoteAssetId != null) {
// Edit stacking is iOS-only; leave the columns untouched on Android so the
// candidate guard and merged-timeline hide clause never engage there.
if (CurrentPlatform.isIOS) {
unawaited(
_localAssetRepository
.markSynced(asset.localId!, priorRemoteId: result.remoteAssetId!, syncedChecksum: asset.checksum)
.catchError(
(Object error, StackTrace stack) =>
_logger.warning(() => "Failed to mark ${asset.localId} synced", error, stack),
),
);
}
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
@@ -396,9 +478,21 @@ class ForegroundUploadService {
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
if (result.errorMessage == _kQuotaError) {
shouldAbortUpload = true;
}
if (result.errorMessage!.contains(kDeadStackParentError)) {
// The stamped prior no longer exists server-side; drop the stamps so
// the next cycle re-resolves fresh instead of looping on the dead id.
unawaited(
_localAssetRepository
.clearSyncStamps(asset.localId!)
.catchError(
(Object error, StackTrace stack) =>
_logger.warning(() => "Failed to clear stamps for ${asset.localId}", error, stack),
),
);
}
}
} catch (error, stackTrace) {
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
@@ -415,6 +509,149 @@ class ForegroundUploadService {
}
}
/// iOS still-image cloudId metadata as a JSON field, or null when there's
/// nothing to attach. The base resource omits adjustmentTime (it's the
/// unedited original); the edit includes it.
String? _cloudMetadata(LocalAsset asset, {required bool includeAdjustment}) {
return cloudMetadataJson(
cloudId: asset.cloudId,
createdAt: asset.createdAt,
adjustmentTime: includeAdjustment ? asset.adjustmentTime?.toIso8601String() : null,
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
/// Persists the uploaded base as the asset's prior so an interrupted run resumes
/// by stacking onto it (AbsorbIntoPrior) instead of re-reading and re-uploading
/// the original. syncedChecksum stays null: the edit itself is still pending.
Future<void> _stampBaseUpload(LocalAsset asset, String baseRemoteId) async {
try {
await _localAssetRepository.markSynced(asset.localId!, priorRemoteId: baseRemoteId, syncedChecksum: null);
} catch (error, stack) {
_logger.warning(() => "Failed to stamp base upload for ${asset.localId}", error, stack);
}
}
/// For an edited iOS photo, uploads the original camera bytes so the edit can
/// stack onto it. See [_StackParent] for the outcome.
Future<_StackParent> _resolveStackParent(
LocalAsset asset,
Map<String, String> baseFields,
Completer<void>? cancelToken, {
bool isLivePhoto = false,
}) async {
if (!CurrentPlatform.isIOS) {
return const _StackParent.none();
}
final plan = await resolveEditPair(
_nativeSyncApi,
asset,
stackRepository: _stackRepository,
ownerId: Store.tryGet(StoreKey.currentUser)?.id,
log: _logger,
isLivePhoto: isLivePhoto,
);
switch (plan) {
case NoEditPair():
return const _StackParent.none();
case DeferEditPair():
return const _StackParent.deferred();
case AbsorbIntoPrior(:final parentId):
return _StackParent.parent(parentId);
case UploadBaseLivePhotoFirst(:final still, :final video):
return _uploadBaseLivePair(asset, baseFields, still, video, cancelToken);
case UploadBaseFirst(:final base):
final baseFile = File(base.path);
try {
final fields = Map.of(baseFields);
final metadata = _cloudMetadata(asset, includeAdjustment: false);
if (metadata != null) {
fields['metadata'] = metadata;
}
final result = await _uploadRepository.uploadFile(
file: baseFile,
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
fields: fields,
cancelToken: cancelToken,
logContext: 'baseResource[${asset.localId}]',
);
if (result.isSuccess && result.remoteAssetId != null) {
await _stampBaseUpload(asset, result.remoteAssetId!);
return _StackParent.parent(result.remoteAssetId!);
}
return _StackParent.failed(errorMessage: result.errorMessage, isCancelled: result.isCancelled);
} finally {
try {
await baseFile.delete();
} catch (_) {}
}
}
}
/// Uploads the original live pair (paired video then still) so the edited live photo
/// can stack onto the original still. Returns the still's remote id as the parent, or
/// [_StackParent.failed] if either hop fails so the edit isn't left unstacked.
Future<_StackParent> _uploadBaseLivePair(
LocalAsset asset,
Map<String, String> baseFields,
BaseResource still,
BaseResource? video,
Completer<void>? cancelToken,
) async {
final stillFile = File(still.path);
final videoFile = video != null ? File(video.path) : null;
try {
final fields = Map.of(baseFields);
String? baseVideoId;
if (videoFile != null) {
final videoResult = await _uploadRepository.uploadFile(
file: videoFile,
originalFileName: p.setExtension(asset.name, p.extension(videoFile.path)),
// hidden so the original motion never flashes onto the timeline (copy: the
// base still upload below reuses `fields`).
fields: {...fields, 'visibility': kHiddenVisibility},
cancelToken: cancelToken,
logContext: 'baseLiveVideo[${asset.localId}]',
);
if (!(videoResult.isSuccess && videoResult.remoteAssetId != null)) {
return _StackParent.failed(errorMessage: videoResult.errorMessage, isCancelled: videoResult.isCancelled);
}
baseVideoId = videoResult.remoteAssetId;
}
if (baseVideoId != null) {
fields['livePhotoVideoId'] = baseVideoId;
}
final metadata = _cloudMetadata(asset, includeAdjustment: false);
if (metadata != null) {
fields['metadata'] = metadata;
}
final stillResult = await _uploadRepository.uploadFile(
file: stillFile,
originalFileName: p.setExtension(asset.name, p.extension(still.path)),
fields: fields,
cancelToken: cancelToken,
logContext: 'baseLiveStill[${asset.localId}]',
);
if (stillResult.isSuccess && stillResult.remoteAssetId != null) {
await _stampBaseUpload(asset, stillResult.remoteAssetId!);
return _StackParent.parent(stillResult.remoteAssetId!);
}
return _StackParent.failed(errorMessage: stillResult.errorMessage, isCancelled: stillResult.isCancelled);
} finally {
try {
await stillFile.delete();
} catch (_) {}
try {
await videoFile?.delete();
} catch (_) {}
}
}
Future<UploadResult> _uploadSingleFile(
File file, {
required String deviceAssetId,
@@ -461,3 +698,42 @@ class ForegroundUploadService {
return true;
}
}
// Server's quota-rejection message (asset-media.service.ts requireQuota).
const String _kQuotaError = "Quota has been exceeded!";
/// Outcome of resolving an edit's stack parent. [stackParentId] is the remote id
/// to stack onto (null when the asset isn't an edit). [baseFailed] is true only
/// when the original was found but its upload failed, so the edit must not be
/// uploaded on its own; [deferred] means skip the asset this cycle (it stays a
/// candidate); [errorMessage]/[isCancelled] carry why a failure happened so the
/// caller can surface it and react to quota/cancel like the main upload does.
class _StackParent {
final String? stackParentId;
final bool baseFailed;
final bool deferred;
final String? errorMessage;
final bool isCancelled;
const _StackParent.none()
: stackParentId = null,
baseFailed = false,
deferred = false,
errorMessage = null,
isCancelled = false;
const _StackParent.parent(String this.stackParentId)
: baseFailed = false,
deferred = false,
errorMessage = null,
isCancelled = false;
const _StackParent.failed({this.errorMessage, this.isCancelled = false})
: stackParentId = null,
baseFailed = true,
deferred = false;
const _StackParent.deferred()
: stackParentId = null,
baseFailed = false,
deferred = true,
errorMessage = null,
isCancelled = false;
}
+13 -3
View File
@@ -1586,8 +1586,11 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [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, Future<void>? abortTrigger, }) 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, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@@ -1651,6 +1654,10 @@ class AssetsApi {
mp.fields[r'sidecarData'] = sidecarData.field;
mp.files.add(sidecarData);
}
if (stackParentId != null) {
hasFields = true;
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
}
if (visibility != null) {
hasFields = true;
mp.fields[r'visibility'] = parameterToString(visibility);
@@ -1711,9 +1718,12 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [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, Future<void>? abortTrigger, }) 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, abortTrigger: abortTrigger,);
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, Future<void>? abortTrigger, }) 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, stackParentId: stackParentId, visibility: visibility, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+35
View File
@@ -103,6 +103,29 @@ class CloudIdResult {
const CloudIdResult({required this.assetId, this.error, this.cloudId});
}
class BaseResource {
final String path;
final String sha1;
const BaseResource({required this.path, required this.sha1});
}
// The readable originals of an edited live photo: the still always, the paired
// video when the asset still carries one. Both are temp copies the caller
// uploads then deletes.
class BaseLivePhoto {
final BaseResource still;
final BaseResource? video;
const BaseLivePhoto({required this.still, this.video});
}
// 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()
abstract class NativeSyncApi {
@async
@@ -143,4 +166,16 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
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});
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
BaseLivePhoto? getBaseLivePhoto(String assetId, {bool allowNetworkAccess = false});
}
+3
View File
@@ -1,3 +1,4 @@
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -7,3 +8,5 @@ class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
class MockConnectivityApi extends Mock implements ConnectivityApi {}
@@ -4,8 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.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/domain/models/asset/base_asset.model.dart' as domain;
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:openapi/api.dart';
@@ -28,26 +32,68 @@ SyncAssetV1 _createAsset({
String ownerId = 'user-1',
int? width,
int? height,
AssetVisibility visibility = AssetVisibility.timeline,
AssetTypeEnum type = AssetTypeEnum.IMAGE,
String? livePhotoVideoId,
String? stackId,
bool isFavorite = false,
DateTime? deletedAt,
}) {
return SyncAssetV1(
id: id,
checksum: checksum,
originalFileName: fileName,
type: AssetTypeEnum.IMAGE,
type: type,
ownerId: ownerId,
isFavorite: false,
isFavorite: isFavorite,
fileCreatedAt: DateTime(2024, 1, 1),
fileModifiedAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
localDateTime: DateTime(2024, 1, 1),
visibility: AssetVisibility.timeline,
visibility: visibility,
width: width,
height: height,
deletedAt: null,
deletedAt: deletedAt,
duration: null,
libraryId: null,
livePhotoVideoId: null,
stackId: null,
livePhotoVideoId: livePhotoVideoId,
stackId: stackId,
thumbhash: null,
isEdited: false,
);
}
SyncAssetV2 _createAssetV2({
required String id,
required String checksum,
required String fileName,
String ownerId = 'user-1',
AssetVisibility visibility = AssetVisibility.timeline,
AssetTypeEnum type = AssetTypeEnum.IMAGE,
String? livePhotoVideoId,
String? stackId,
bool isFavorite = false,
DateTime? deletedAt,
}) {
return SyncAssetV2(
id: id,
checksum: checksum,
originalFileName: fileName,
type: type,
ownerId: ownerId,
isFavorite: isFavorite,
fileCreatedAt: DateTime(2024, 1, 1),
fileModifiedAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
localDateTime: DateTime(2024, 1, 1),
visibility: visibility,
width: null,
height: null,
deletedAt: deletedAt,
duration: null,
libraryId: null,
livePhotoVideoId: livePhotoVideoId,
stackId: stackId,
thumbhash: null,
isEdited: false,
);
@@ -189,6 +235,168 @@ void main() {
});
});
group('SyncStreamRepository - websocket fast-path link state', () {
Future<RemoteAssetEntityData> read(String id) =>
(db.remoteAssetEntity.select()..where((t) => t.id.equals(id))).getSingle();
test('fromWebsocket does not clobber visibility the checkpoint sync already hid', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'motion-video';
// checkpoint sync stored the real server state: a hidden motion video
await sut.updateAssetsV1([
_createAsset(
id: id,
checksum: 'cs',
fileName: 'IMG.mov',
type: AssetTypeEnum.VIDEO,
visibility: AssetVisibility.hidden,
),
]);
// a stale upload-ready event arrives with the upload-time state (timeline)
await sut.updateAssetsV1([
_createAsset(
id: id,
checksum: 'cs',
fileName: 'IMG.mov',
type: AssetTypeEnum.VIDEO,
visibility: AssetVisibility.timeline,
),
], fromWebsocket: true);
expect((await read(id)).visibility, domain.AssetVisibility.hidden);
});
test('authoritative sync (default) still overwrites visibility', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'asset-1';
await sut.updateAssetsV1([
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.hidden),
]);
await sut.updateAssetsV1([
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
]);
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
});
test('fromWebsocket still inserts a new asset with its payload visibility', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'new-asset';
await sut.updateAssetsV1([
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
], fromWebsocket: true);
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
});
test('fromWebsocket conflict keeps checkpoint livePhotoVideoId and stackId but applies other fields', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'edited-still-1';
const stackId = 'stack-001';
await db.stackEntity.insertOne(StackEntityCompanion.insert(id: stackId, ownerId: 'user-1', primaryAssetId: id));
// checkpoint linked the edited still to its base pair
await sut.updateAssetsV1([
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', livePhotoVideoId: 'live-vid-001', stackId: stackId),
]);
// stale websocket snapshot from upload time: no links yet, but favorite since
await sut.updateAssetsV1([
_createAsset(id: id, checksum: 'cs', fileName: 'IMG_RENAMED.heic', isFavorite: true),
], fromWebsocket: true);
final row = await read(id);
expect(row.livePhotoVideoId, 'live-vid-001');
expect(row.stackId, stackId);
expect(row.name, 'IMG_RENAMED.heic', reason: 'non-link fields from the websocket payload must still apply');
expect(row.isFavorite, isTrue);
});
test('fromWebsocket conflict does not resurrect an asset the checkpoint trashed', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'trashed-asset';
await sut.updateAssetsV1([
_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic', deletedAt: DateTime(2024, 2, 1)),
]);
// debounced upload-ready snapshot always carries deletedAt null
await sut.updateAssetsV1([_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic')], fromWebsocket: true);
expect((await read(id)).deletedAt, isNotNull);
});
test('authoritative sync (default) still overwrites livePhotoVideoId, stackId and deletedAt', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'unstacked-asset';
const stackId = 'stack-002';
await db.stackEntity.insertOne(StackEntityCompanion.insert(id: stackId, ownerId: 'user-1', primaryAssetId: id));
await sut.updateAssetsV1([
_createAsset(
id: id,
checksum: 'cs',
fileName: 'IMG.heic',
livePhotoVideoId: 'live-vid-002',
stackId: stackId,
deletedAt: DateTime(2024, 2, 1),
),
]);
// server unstacked + restored the asset; checkpoint sync must win
await sut.updateAssetsV1([_createAsset(id: id, checksum: 'cs', fileName: 'IMG.heic')]);
final row = await read(id);
expect(row.livePhotoVideoId, isNull);
expect(row.stackId, isNull);
expect(row.deletedAt, isNull);
});
test('fromWebsocket does not clobber visibility through updateAssetsV2', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'motion-video-v2';
await sut.updateAssetsV2([
_createAssetV2(
id: id,
checksum: 'cs',
fileName: 'IMG.mov',
type: AssetTypeEnum.VIDEO,
visibility: AssetVisibility.hidden,
),
]);
await sut.updateAssetsV2([
_createAssetV2(
id: id,
checksum: 'cs',
fileName: 'IMG.mov',
type: AssetTypeEnum.VIDEO,
visibility: AssetVisibility.timeline,
),
], fromWebsocket: true);
expect((await read(id)).visibility, domain.AssetVisibility.hidden);
});
test('fromWebsocket still inserts a new asset through updateAssetsV2', () async {
await sut.updateUsersV1([_createUser()]);
const id = 'new-asset-v2';
await sut.updateAssetsV2([
_createAssetV2(id: id, checksum: 'cs', fileName: 'IMG.heic', visibility: AssetVisibility.timeline),
], fromWebsocket: true);
expect((await read(id)).visibility, domain.AssetVisibility.timeline);
});
});
group('SyncStreamRepository - reset()', () {
test('nulls linkedRemoteAlbumId on localAlbumEntity so FK refs do not dangle', () async {
const localAlbumId = 'local-1';
@@ -239,5 +447,32 @@ void main() {
expect(after.name, equals('Camera'));
expect(after.backupSelection, equals(BackupSelection.none));
});
test('nulls priorRemoteId and syncedChecksum on localAssetEntity but keeps the row', () async {
const localId = 'local-edited';
await db.localAssetEntity.insertOne(
LocalAssetEntityCompanion.insert(
id: localId,
name: 'IMG.heic',
type: domain.AssetType.image,
checksum: const drift.Value('cs-local'),
priorRemoteId: const drift.Value('prior-remote-1'),
syncedChecksum: const drift.Value('cs-synced'),
),
);
await sut.reset();
final after = await (db.localAssetEntity.select()..where((t) => t.id.equals(localId))).getSingle();
expect(
after.priorRemoteId,
isNull,
reason: 'the remote rows the stamps point at were wiped — a later backup must not stack onto dead ids',
);
expect(after.syncedChecksum, isNull);
expect(after.name, equals('IMG.heic'), reason: 'local asset row itself must be preserved');
expect(after.checksum, equals('cs-local'));
});
});
}
+3
View File
@@ -1,3 +1,4 @@
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
@@ -14,3 +15,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockPartnerService extends Mock implements PartnerService {}
class MockEditRevertService extends Mock implements EditRevertService {}
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -36,7 +37,6 @@ class _AbortCallbackWrapper {
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
@@ -620,4 +620,174 @@ void main() {
verifyNever(() => mockSyncMigrationRepo.v20260128CopyExifWidthHeightToAsset());
});
});
group('SyncStreamService - websocket fromWebsocket plumbing', () {
SyncAssetV1 wsAssetV1(String id) => SyncAssetV1(
checksum: 'checksum-$id',
createdAt: DateTime(2025, 1, 2),
deletedAt: null,
duration: null,
fileCreatedAt: DateTime(2025),
fileModifiedAt: DateTime(2025, 1, 2),
height: null,
id: id,
isEdited: false,
isFavorite: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: DateTime(2025, 1, 3),
originalFileName: '$id.jpg',
ownerId: 'owner',
stackId: null,
thumbhash: null,
type: AssetTypeEnum.IMAGE,
visibility: AssetVisibility.timeline,
width: null,
);
SyncAssetV2 wsAssetV2(String id) => SyncAssetV2(
checksum: 'checksum-$id',
createdAt: DateTime(2025, 1, 2),
deletedAt: null,
duration: null,
fileCreatedAt: DateTime(2025),
fileModifiedAt: DateTime(2025, 1, 2),
height: null,
id: id,
isEdited: false,
isFavorite: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: DateTime(2025, 1, 3),
originalFileName: '$id.jpg',
ownerId: 'owner',
stackId: null,
thumbhash: null,
type: AssetTypeEnum.IMAGE,
visibility: AssetVisibility.timeline,
width: null,
);
SyncAssetExifV1 wsExif(String id) => SyncAssetExifV1(
assetId: id,
city: null,
country: null,
dateTimeOriginal: null,
description: null,
exifImageHeight: null,
exifImageWidth: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: null,
model: null,
modifyDate: null,
orientation: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
);
// toJson keeps enums as objects; round-trip so fromJson sees plain JSON
Map<String, dynamic> wsPayload(Map<String, dynamic> payload) =>
jsonDecode(jsonEncode(payload)) as Map<String, dynamic>;
setUp(() {
// stubs registered without fromWebsocket won't match calls that pass it
when(
() => mockSyncStreamRepo.updateAssetsV1(
any(),
debugLabel: any(named: 'debugLabel'),
fromWebsocket: any(named: 'fromWebsocket'),
),
).thenAnswer(successHandler);
when(
() => mockSyncStreamRepo.updateAssetsV2(
any(),
debugLabel: any(named: 'debugLabel'),
fromWebsocket: any(named: 'fromWebsocket'),
),
).thenAnswer(successHandler);
when(
() => mockSyncStreamRepo.replaceAssetEditsV1(any(), any(), debugLabel: any(named: 'debugLabel')),
).thenAnswer(successHandler);
});
test('handleWsAssetUploadReadyV1Batch passes fromWebsocket true', () async {
await sut.handleWsAssetUploadReadyV1Batch([
wsPayload({'asset': wsAssetV1('ws-v1').toJson(), 'exif': wsExif('ws-v1').toJson()}),
]);
verify(
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
).called(1);
verify(() => mockSyncStreamRepo.updateAssetsExifV1(any(), debugLabel: any(named: 'debugLabel'))).called(1);
verifyNever(
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
);
});
test('handleWsAssetUploadReadyV2Batch passes fromWebsocket true', () async {
await sut.handleWsAssetUploadReadyV2Batch([
wsPayload({'asset': wsAssetV2('ws-v2').toJson(), 'exif': wsExif('ws-v2').toJson()}),
]);
verify(
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
).called(1);
verify(() => mockSyncStreamRepo.updateAssetsExifV1(any(), debugLabel: any(named: 'debugLabel'))).called(1);
verifyNever(
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
);
});
test('handleWsAssetEditReadyV1 passes fromWebsocket true', () async {
await sut.handleWsAssetEditReadyV1(wsPayload({'asset': wsAssetV1('ws-edit-v1').toJson(), 'edit': []}));
verify(
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
).called(1);
verify(
() => mockSyncStreamRepo.replaceAssetEditsV1('ws-edit-v1', any(), debugLabel: any(named: 'debugLabel')),
).called(1);
verifyNever(
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
);
});
test('handleWsAssetEditReadyV2 passes fromWebsocket true', () async {
await sut.handleWsAssetEditReadyV2(wsPayload({'asset': wsAssetV2('ws-edit-v2').toJson(), 'edit': []}));
verify(
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
).called(1);
verify(
() => mockSyncStreamRepo.replaceAssetEditsV1('ws-edit-v2', any(), debugLabel: any(named: 'debugLabel')),
).called(1);
verifyNever(
() => mockSyncStreamRepo.updateAssetsV2(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
);
});
test('checkpoint sync keeps fromWebsocket false', () async {
await simulateEvents([
SyncStreamStub.assetModified(id: 'remote-checkpoint', checksum: 'checksum-cp', ack: 'cp-ack'),
]);
verify(
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: false),
).called(1);
verifyNever(
() => mockSyncStreamRepo.updateAssetsV1(any(), debugLabel: any(named: 'debugLabel'), fromWebsocket: true),
);
});
});
}
+4
View File
@@ -33,6 +33,7 @@ import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -96,6 +97,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v28.DatabaseAtV28(db);
case 29:
return v29.DatabaseAtV29(db);
case 30:
return v30.DatabaseAtV30(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -131,5 +134,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
27,
28,
29,
30,
];
}
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,17 @@
import 'package:drift/drift.dart';
import 'package:drift/native.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
void main() {
late Drift db;
@@ -18,13 +24,64 @@ void main() {
await db.close();
});
Future<void> insertUser(String id) =>
db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: '$id@test.dev', name: id));
Future<void> insertRemote(
String id,
String ownerId, {
required String checksum,
required DateTime at,
DateTime? deletedAt,
}) => db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: id,
name: '$id.jpg',
type: AssetType.image,
checksum: checksum,
ownerId: ownerId,
visibility: AssetVisibility.timeline,
createdAt: Value(at),
updatedAt: Value(at),
uploadedAt: Value(at),
deletedAt: Value(deletedAt),
),
);
Future<void> insertLocal(String id, {required DateTime at, String? checksum, String? priorRemoteId}) => db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: id,
name: '$id.jpg',
type: AssetType.image,
checksum: Value(checksum),
priorRemoteId: Value(priorRemoteId),
createdAt: Value(at),
updatedAt: Value(at),
),
);
Future<void> insertSelectedAlbumWith(String albumId, List<String> assetIds) async {
await db
.into(db.localAlbumEntity)
.insert(
LocalAlbumEntityCompanion.insert(id: albumId, name: albumId, backupSelection: BackupSelection.selected),
);
for (final assetId in assetIds) {
await db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(assetId: assetId, albumId: albumId));
}
}
test('mergedBucket falls back to createdAt when localDateTime is null', () async {
const userId = 'user-1';
final createdAt = DateTime(2024, 1, 1, 12);
await db
.into(db.userEntity)
.insert(UserEntityCompanion.insert(id: userId, email: 'user-1@test.dev', name: 'User 1'));
await insertUser(userId);
await db
.into(db.remoteAssetEntity)
@@ -49,4 +106,150 @@ void main() {
expect(buckets.single.assetCount, 1);
expect(buckets.single.bucketDate, isNotEmpty);
});
// Reproduces the on-server shape of an edited live photo: 2 stills stacked
// (primary = the edit) + 2 hidden motion videos. The hidden videos must never
// become timeline tiles, and the stack collapses to its primary still.
test('edited live photo: hidden motion videos excluded, stack collapses to its primary', () async {
const userId = 'user-1';
final t = DateTime(2026, 6, 8, 9);
await insertUser(userId);
await db
.into(db.stackEntity)
.insert(StackEntityCompanion.insert(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit-still'));
Future<void> ins(String id, AssetType type, AssetVisibility vis, {String? lpv, String? stackId}) => db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: id,
name: '$id.x',
type: type,
checksum: 'cs-$id',
ownerId: userId,
visibility: vis,
createdAt: Value(t),
updatedAt: Value(t),
uploadedAt: Value(t),
livePhotoVideoId: Value(lpv),
stackId: Value(stackId),
),
);
await ins('orig-still', AssetType.image, AssetVisibility.timeline, lpv: 'orig-video', stackId: 'stack-1');
await ins('edit-still', AssetType.image, AssetVisibility.timeline, lpv: 'edit-video', stackId: 'stack-1');
await ins('orig-video', AssetType.video, AssetVisibility.hidden);
await ins('edit-video', AssetType.video, AssetVisibility.hidden);
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
final ids = rows.map((r) => r.remoteId).toList();
expect(ids, isNot(contains('edit-video')), reason: 'hidden edit motion video must not be a timeline tile');
expect(ids, isNot(contains('orig-video')), reason: 'hidden orig motion video must not be a timeline tile');
expect(ids, isNot(contains('orig-still')), reason: 'non-primary stack member collapses behind the primary');
expect(ids, ['edit-still'], reason: 'the stack shows exactly once, as its primary');
});
test('local tile hidden when prior_remote_id points at a live remote', () async {
const userId = 'user-1';
final t = DateTime(2026, 6, 8, 9);
await insertUser(userId);
await insertRemote('live-remote', userId, checksum: 'cs-server', at: t);
// re-encoded bytes: checksum no longer matches the remote, but prior does
await insertLocal('hidden-local', at: t, checksum: 'cs-rerendered', priorRemoteId: 'live-remote');
await insertLocal('plain-local', at: t);
await insertSelectedAlbumWith('album-1', ['hidden-local', 'plain-local']);
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
final localOnlyIds = rows.where((r) => r.remoteId == null).map((r) => r.localId).toList();
expect(localOnlyIds, isNot(contains('hidden-local')), reason: 'local already live on server must not get a tile');
expect(localOnlyIds, contains('plain-local'));
expect(rows, hasLength(2));
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
final bucketTotal = buckets.fold<int>(0, (sum, b) => sum + b.assetCount);
expect(bucketTotal, rows.length, reason: 'bucket counts must match the visible tiles');
});
test('local tile hidden when the prior remote is trashed', () async {
const userId = 'user-1';
final t = DateTime(2026, 6, 8, 9);
await insertUser(userId);
await insertRemote('trashed-remote', userId, checksum: 'cs-server', at: t, deletedAt: t);
await insertLocal('local-1', at: t, checksum: 'cs-rerendered', priorRemoteId: 'trashed-remote');
await insertSelectedAlbumWith('album-1', ['local-1']);
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
expect(rows, isEmpty, reason: 'trashing on the server must not pop the photo back onto the local timeline');
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
expect(buckets, isEmpty);
});
test('local tile shows again when the prior remote row is gone', () async {
const userId = 'user-1';
final t = DateTime(2026, 6, 8, 9);
await insertUser(userId);
// hard delete: sync removed the remote row entirely, only that re-opens the local
await insertLocal('local-1', at: t, checksum: 'cs-rerendered', priorRemoteId: 'gone-remote');
await insertSelectedAlbumWith('album-1', ['local-1']);
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
expect(rows, hasLength(1));
expect(rows.single.remoteId, null);
expect(rows.single.localId, 'local-1');
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
expect(buckets.fold<int>(0, (sum, b) => sum + b.assetCount), 1);
});
test('remote row falls back to prior_remote_id for local_id and local_checksum', () async {
const userId = 'user-1';
final t = DateTime(2026, 6, 8, 9);
await insertUser(userId);
await insertRemote('remote-1', userId, checksum: 'cs-server', at: t);
await insertLocal('local-1', at: t, checksum: 'cs-on-device', priorRemoteId: 'remote-1');
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
final row = rows.single;
expect(row.remoteId, 'remote-1');
expect(row.localId, 'local-1');
expect(row.localChecksum, 'cs-on-device', reason: 'local render key must be the on-device bytes');
expect(row.checksum, 'cs-server');
});
test('checksum match links local_id and local_checksum', () async {
const userId = 'user-1';
final t = DateTime(2026, 6, 8, 9);
await insertUser(userId);
await insertRemote('remote-1', userId, checksum: 'cs-same', at: t);
await insertLocal('local-1', at: t, checksum: 'cs-same');
final rows = await db.mergedAssetDrift.mergedAsset(userIds: [userId], limit: (_) => Limit(1000, 0)).get();
final row = rows.single;
expect(row.remoteId, 'remote-1');
expect(row.localId, 'local-1');
expect(row.localChecksum, 'cs-same');
expect(row.localChecksum, row.checksum);
});
test('timeline repository maps local_checksum into RemoteAsset.localChecksum', () async {
const userId = 'user-1';
final t = DateTime(2026, 6, 8, 9);
await insertUser(userId);
await insertRemote('remote-match', userId, checksum: 'cs-same', at: t);
await insertLocal('local-match', at: t, checksum: 'cs-same');
await insertRemote('remote-prior', userId, checksum: 'cs-server', at: t);
await insertLocal('local-prior', at: t, checksum: 'cs-on-device', priorRemoteId: 'remote-prior');
final assets = await DriftTimelineRepository(db).main([userId], GroupAssetsBy.day).assetSource(0, 100);
final byId = {for (final a in assets.whereType<RemoteAsset>()) a.id: a};
expect(byId, hasLength(2));
expect(byId['remote-match']?.localChecksum, 'cs-same');
expect(byId['remote-prior']?.localChecksum, 'cs-on-device', reason: 'prior-linked local with re-encoded bytes');
});
}
@@ -6,6 +6,7 @@ import 'package:immich_mobile/infrastructure/repositories/partner.repository.dar
import 'package:immich_mobile/infrastructure/repositories/settings.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/stack.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/sync_api.repository.dart';
@@ -38,6 +39,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
@@ -135,6 +135,59 @@ void main() {
expect(result.remainder, 2); // local2 + local3
expect(result.processing, 1); // local3
});
test('reconciled asset with live prior remote counts in total but not remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
// uploaded as edit pair: prior remote is live, but no remote row matches the local checksum
final prior = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: 'edited-1', syncedChecksum: 'edited-1', priorRemoteId: prior.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('reverted-handled asset counts in total but not remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
// revert handled: local re-hashed fresh, stamped synced + prior pointing at the base remote
final prior = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(
checksum: 'reverted-1',
syncedChecksum: 'reverted-1',
priorRemoteId: prior.id,
);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 0);
});
test('reconciled asset with trashed prior remote stays out of remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
// prior was trashed, not hard-deleted: the row still exists, so the revert
// stays handled — only a missing row re-opens the asset
final prior = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2025, 6));
final local = await ctx.newLocalAsset(checksum: 'edited-3', syncedChecksum: 'edited-3', priorRemoteId: prior.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 0);
});
test('reconciled asset with hard-deleted prior remote counts in remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
// prior remote row is gone -> needs re-upload
final local = await ctx.newLocalAsset(checksum: 'edited-2', syncedChecksum: 'edited-2', priorRemoteId: 'gone');
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
});
});
group('getCandidates', () {
@@ -240,5 +293,64 @@ void main() {
expect(result.length, 1);
expect(result.first.id, asset.id);
});
test('includes re-edited asset whose synced checksum is stale', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final prior = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: 'edit-v2', syncedChecksum: 'edit-v1', priorRemoteId: prior.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, local.id);
});
test('excludes reconciled asset with live prior remote', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final prior = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(
checksum: 'reverted-2',
syncedChecksum: 'reverted-2',
priorRemoteId: prior.id,
);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('excludes reconciled asset whose prior remote was trashed', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final prior = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2025, 6));
final local = await ctx.newLocalAsset(
checksum: 'reverted-3',
syncedChecksum: 'reverted-3',
priorRemoteId: prior.id,
);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('includes reconciled asset whose prior remote was hard-deleted', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final local = await ctx.newLocalAsset(checksum: 'edit-v3', syncedChecksum: 'edit-v3', priorRemoteId: 'gone');
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, local.id);
});
test('includes asset with null checksum and synced checksum set when onlyHashed is false', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none(), syncedChecksum: 'old');
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId, onlyHashed: false);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
});
}
@@ -0,0 +1,301 @@
import 'package:flutter_test/flutter_test.dart';
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 '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftStackRepository sut;
late String userId;
setUp(() async {
ctx = MediumRepositoryContext();
sut = DriftStackRepository(ctx.db);
final user = await ctx.newUser();
userId = user.id;
});
tearDown(() async {
await ctx.dispose();
});
group('priorState', () {
test('live for a live remote', () async {
await ctx.newRemoteAsset(id: 'live', ownerId: userId);
expect(await sut.priorState('live'), PriorState.live);
});
test('trashed for a trashed remote', () async {
await ctx.newRemoteAsset(id: 'trashed', ownerId: userId, deletedAt: DateTime(2025, 6));
expect(await sut.priorState('trashed'), PriorState.trashed);
});
test('trashed for a locked remote (server refuses to stack onto it)', () async {
await ctx.newRemoteAsset(id: 'locked', ownerId: userId, visibility: AssetVisibility.locked);
expect(await sut.priorState('locked'), PriorState.trashed);
});
test('missing for a remote that was never synced', () async {
expect(await sut.priorState('missing'), PriorState.missing);
});
});
group('remoteByChecksum', () {
test('returns live with the id for a live owned remote', () async {
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'base-sum');
final dup = await sut.remoteByChecksum('base-sum', userId);
expect(dup.state, PriorState.live);
expect(dup.remoteId, 'remote-1');
});
test('returns trashed with the id for a trashed owned remote', () async {
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'base-sum', deletedAt: DateTime(2025, 6));
final dup = await sut.remoteByChecksum('base-sum', userId);
expect(dup.state, PriorState.trashed);
expect(dup.remoteId, 'remote-1');
});
test('returns trashed with the id for a locked owned remote', () async {
await ctx.newRemoteAsset(
id: 'remote-1',
ownerId: userId,
checksum: 'base-sum',
visibility: AssetVisibility.locked,
);
final dup = await sut.remoteByChecksum('base-sum', userId);
expect(dup.state, PriorState.trashed);
expect(dup.remoteId, 'remote-1');
});
test('returns missing when no remote has the bytes', () async {
await ctx.newRemoteAsset(id: 'remote-1', ownerId: userId, checksum: 'other-sum');
final dup = await sut.remoteByChecksum('base-sum', userId);
expect(dup.state, PriorState.missing);
expect(dup.remoteId, isNull);
});
test("ignores another user's remote with the same bytes (owner-scoped)", () async {
final other = await ctx.newUser();
await ctx.newRemoteAsset(id: 'theirs', ownerId: other.id, checksum: 'base-sum');
final dup = await sut.remoteByChecksum('base-sum', userId);
expect(dup.state, PriorState.missing);
expect(dup.remoteId, isNull);
});
});
group('findStackIdByRemoteId', () {
test('returns the stack id for a stacked remote', () async {
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id);
expect(await sut.findStackIdByRemoteId('edit'), stack.id);
});
test('returns null for an unstacked remote', () async {
await ctx.newRemoteAsset(id: 'lonely', ownerId: userId);
expect(await sut.findStackIdByRemoteId('lonely'), isNull);
});
test('returns null for a trashed remote', () async {
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id, deletedAt: DateTime(2025, 6));
expect(await sut.findStackIdByRemoteId('edit'), isNull);
});
});
group('findStackBaseId', () {
test('returns the earliest-uploaded member that is not the excluded one', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
// base uploaded before the edit → it's the flip target.
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
});
test('returns null when the only member is excluded', () async {
final base = await ctx.newRemoteAsset(id: 'solo', ownerId: userId, stackId: 'stack-1');
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: base.id);
expect(await sut.findStackBaseId('stack-1', excludeId: 'solo'), isNull);
});
test('skips trashed members', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(
id: 'base',
ownerId: userId,
stackId: 'stack-1',
uploadedAt: DateTime(2025),
deletedAt: DateTime(2025, 6),
);
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), isNull);
});
test('live shape: unstacked hidden motion videos can never win', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit-still');
await ctx.newRemoteAsset(id: 'base-still', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
await ctx.newRemoteAsset(id: 'edit-still', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
// The live pair's motion videos: hidden, no stack, uploaded before everything.
await ctx.newRemoteAsset(
id: 'base-video',
ownerId: userId,
type: AssetType.video,
visibility: AssetVisibility.hidden,
uploadedAt: DateTime(2024),
);
await ctx.newRemoteAsset(
id: 'edit-video',
ownerId: userId,
type: AssetType.video,
visibility: AssetVisibility.hidden,
uploadedAt: DateTime(2024, 2),
);
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit-still'), 'base-still');
});
test('oldest member wins even when it is a dedup-reused edit (known limit)', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'prior-edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
// An edit re-used by server dedup, uploaded before the base ever was.
await ctx.newRemoteAsset(id: 'dedup-edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
await ctx.newRemoteAsset(id: 'prior-edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 3));
// Heuristic is oldest uploaded_at, so the reused edit beats the real base.
expect(await sut.findStackBaseId('stack-1', excludeId: 'prior-edit'), 'dedup-edit');
});
test('a member with NULL uploaded_at sorts last', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'unsynced', ownerId: userId, stackId: 'stack-1');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 3));
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
});
});
group('findRevertReconcileTargets', () {
test('finds a local that hashed back to a non-primary stack member', () async {
// Stack: primary = edit, also holds base. The local's checksum matches base.
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
final targets = await sut.findRevertReconcileTargets();
expect(targets, hasLength(1));
expect(targets.first.stackId, 'stack-1');
expect(targets.first.newPrimaryId, 'base');
expect(targets.first.localAssetId, 'local-1');
expect(targets.first.localAssetChecksum, 'base-sum');
});
test('stops matching once the primary flips to the matched member', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
expect(await sut.findRevertReconcileTargets(), hasLength(1));
await sut.setPrimary('stack-1', 'base');
expect(await sut.findRevertReconcileTargets(), isEmpty);
});
test('returns nothing when the local already matches the primary', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
await ctx.newLocalAsset(id: 'local-1', checksum: 'edit-sum', priorRemoteId: 'edit');
expect(await sut.findRevertReconcileTargets(), isEmpty);
});
test('ignores a local whose prior remote was trashed', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(
id: 'edit',
ownerId: userId,
stackId: 'stack-1',
checksum: 'edit-sum',
deletedAt: DateTime(2025, 6),
);
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
expect(await sut.findRevertReconcileTargets(), isEmpty);
});
test('ignores a local whose prior is not in any stack', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
await ctx.newRemoteAsset(id: 'unstacked', ownerId: userId, checksum: 'other-sum');
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'unstacked');
expect(await sut.findRevertReconcileTargets(), isEmpty);
});
test('leaves a manual stack of two backed-up locals alone (no ping-pong)', () async {
// The user stacked two ordinary photos by hand. Each local is steady-state:
// synced == checksum and prior = its own member, so neither side may ever
// flip the primary back and forth.
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'remote-a');
await ctx.newRemoteAsset(id: 'remote-a', ownerId: userId, stackId: 'stack-1', checksum: 'a-sum');
await ctx.newRemoteAsset(id: 'remote-b', ownerId: userId, stackId: 'stack-1', checksum: 'b-sum');
await ctx.newLocalAsset(id: 'local-a', checksum: 'a-sum', syncedChecksum: 'a-sum', priorRemoteId: 'remote-a');
await ctx.newLocalAsset(id: 'local-b', checksum: 'b-sum', syncedChecksum: 'b-sum', priorRemoteId: 'remote-b');
expect(await sut.findRevertReconcileTargets(), isEmpty);
});
test('finds a true revert: prior is the edit, checksum hashed back to the base', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
// Unreconciled: the chain last synced the edit bytes, the local now holds the base bytes.
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', syncedChecksum: 'edit-sum', priorRemoteId: 'edit');
final targets = await sut.findRevertReconcileTargets();
expect(targets, hasLength(1));
expect(targets.first.stackId, 'stack-1');
expect(targets.first.newPrimaryId, 'base');
expect(targets.first.localAssetId, 'local-1');
expect(targets.first.localAssetChecksum, 'base-sum');
});
test('stops matching once the flip writes synced = checksum', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', syncedChecksum: 'edit-sum', priorRemoteId: 'edit');
final targets = await sut.findRevertReconcileTargets();
expect(targets, hasLength(1));
// The reconcile flip rolls the stamps forward; that's what makes it self-limiting.
final target = targets.first;
await DriftLocalAssetRepository(
ctx.db,
).markSynced(target.localAssetId, priorRemoteId: target.newPrimaryId, syncedChecksum: target.localAssetChecksum);
expect(await sut.findRevertReconcileTargets(), isEmpty);
});
});
}
@@ -15,6 +15,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
@@ -99,6 +100,7 @@ class MediumRepositoryContext {
String? stackId,
String? thumbHash,
String? libraryId,
DateTime? uploadedAt,
}) async {
id ??= TestUtils.uuid();
createdAt ??= TestUtils.date();
@@ -125,6 +127,19 @@ class MediumRepositoryContext {
localDateTime: .new(createdAt.toLocal()),
thumbHash: .new(TestUtils.uuid(thumbHash)),
libraryId: .new(TestUtils.uuid(libraryId)),
uploadedAt: .new(uploadedAt),
),
);
}
Future<StackEntityData> newStack({String? id, String? ownerId, required String primaryAssetId}) {
return db
.into(db.stackEntity)
.insertReturning(
StackEntityCompanion(
id: .new(TestUtils.uuid(id)),
ownerId: .new(TestUtils.uuid(ownerId)),
primaryAssetId: .new(primaryAssetId),
),
);
}
@@ -258,6 +273,8 @@ class MediumRepositoryContext {
int? durationMs,
int? orientation,
DateTime? updatedAt,
String? priorRemoteId,
String? syncedChecksum,
}) async {
id ??= TestUtils.uuid();
return db
@@ -279,6 +296,8 @@ class MediumRepositoryContext {
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
priorRemoteId: .new(priorRemoteId),
syncedChecksum: .new(syncedChecksum),
),
);
}
@@ -0,0 +1,196 @@
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import '../../../unit/presentation_context.dart';
void main() {
late Drift db;
setUpAll(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await SettingsRepository.ensureInitialized(db);
});
tearDownAll(() async {
await db.close();
});
LocalAsset makeLocalAsset(String id, {String? checksum}) => LocalAsset(
id: id,
name: '$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
RemoteAsset makeMergedRemoteAsset({String? localChecksum}) => RemoteAsset(
id: 'R',
localId: 'X',
name: 'x.jpg',
ownerId: 'owner',
checksum: 'server',
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025),
isEdited: false,
localChecksum: localChecksum,
);
group('local image provider cache keys include checksum', () {
test('LocalThumbProvider with a different checksum is a different key', () {
final c1 = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
final c2 = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c2');
final c1Again = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
// an on-device edit keeps the id but re-hashes to a new checksum → must miss the cache
expect(c1 == c2, isFalse);
expect(c1.hashCode == c2.hashCode, isFalse);
// same id + same checksum → same key (cache hit)
expect(c1 == c1Again, isTrue);
expect(c1.hashCode, c1Again.hashCode);
});
test('LocalFullImageProvider with a different checksum is a different key', () {
final c1 = LocalFullImageProvider(
id: 'L',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
checksum: 'c1',
);
final c2 = LocalFullImageProvider(
id: 'L',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
checksum: 'c2',
);
expect(c1 == c2, isFalse);
expect(c1.hashCode == c2.hashCode, isFalse);
});
test('LocalThumbProvider null vs non-null checksum is a different key', () {
final unhashed = LocalThumbProvider(id: 'L', assetType: AssetType.image);
final hashed = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1');
// a rehash takes the checksum null → 'c1'; the stale render must not be reused
expect(unhashed == hashed, isFalse);
expect(hashed == unhashed, isFalse);
});
test('LocalFullImageProvider null vs non-null checksum is a different key', () {
final unhashed = LocalFullImageProvider(
id: 'L',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
);
final hashed = LocalFullImageProvider(
id: 'L',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
checksum: 'c1',
);
expect(unhashed == hashed, isFalse);
expect(hashed == unhashed, isFalse);
});
test('LocalThumbProvider equality ignores size', () {
final small = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1', size: const Size(50, 50));
final big = LocalThumbProvider(id: 'L', assetType: AssetType.image, checksum: 'c1', size: const Size(200, 200));
// viewer fast-path: any cached thumb render is reusable regardless of requested size
expect(small == big, isTrue);
expect(small.hashCode, big.hashCode);
});
test('LocalFullImageProvider with same id, size, isAnimated and checksum is equal', () {
final a = LocalFullImageProvider(
id: 'L',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
checksum: 'c1',
);
final b = LocalFullImageProvider(
id: 'L',
assetType: AssetType.image,
size: const Size(100, 100),
isAnimated: false,
checksum: 'c1',
);
expect(a == b, isTrue);
expect(a.hashCode, b.hashCode);
});
});
group('factory checksum plumbing', () {
test('getThumbnailImageProvider carries the local asset checksum', () {
final provider = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c1'));
expect(provider, isA<LocalThumbProvider>());
expect((provider as LocalThumbProvider).checksum, 'c1');
});
test('getFullImageProvider carries the local asset checksum', () {
final provider = getFullImageProvider(makeLocalAsset('X', checksum: 'c1'));
expect(provider, isA<LocalFullImageProvider>());
expect((provider as LocalFullImageProvider).checksum, 'c1');
});
test('same id with a different checksum produces unequal providers', () {
final thumb1 = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c1'));
final thumb2 = getThumbnailImageProvider(makeLocalAsset('X', checksum: 'c2'));
final full1 = getFullImageProvider(makeLocalAsset('X', checksum: 'c1'));
final full2 = getFullImageProvider(makeLocalAsset('X', checksum: 'c2'));
expect(thumb1 == thumb2, isFalse);
expect(full1 == full2, isFalse);
});
});
group('RemoteAsset localChecksum preference', () {
test('merged remote keys local renders by localChecksum when set', () {
final asset = makeMergedRemoteAsset(localChecksum: 'device');
// localId set → hasLocal, so the factory takes the local path
expect(asset.hasLocal, isTrue);
final thumb = getThumbnailImageProvider(asset);
expect(thumb, isA<LocalThumbProvider>());
expect((thumb as LocalThumbProvider).checksum, 'device');
final full = getFullImageProvider(asset);
expect(full, isA<LocalFullImageProvider>());
expect((full as LocalFullImageProvider).checksum, 'device');
});
test('merged remote without a localChecksum renders the remote, not the local bytes', () async {
// A prior-linked local that hasn't rehashed yet has no trustworthy cache
// key — its bytes may differ from the server checksum.
await PresentationContext.create();
final asset = makeMergedRemoteAsset();
final thumb = getThumbnailImageProvider(asset);
expect(thumb, isNot(isA<LocalThumbProvider>()));
final full = getFullImageProvider(asset);
expect(full, isNot(isA<LocalFullImageProvider>()));
});
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,642 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../api.mocks.dart';
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
void main() {
late ForegroundUploadService sut;
late MockUploadRepository mockUpload;
late MockStorageRepository mockStorage;
late MockDriftBackupRepository mockBackup;
late MockConnectivityApi mockConnectivity;
late MockAssetMediaRepository mockAssetMedia;
late MockNativeSyncApi mockNativeApi;
late MockDriftLocalAssetRepository mockLocalAsset;
late MockEditRevertService mockEditRevert;
late MockDriftStackRepository mockStack;
late Drift db;
late Directory tmp;
final edited = LocalAsset(
id: 'edited-1',
name: 'edited-1.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1, 12),
updatedAt: DateTime(2025, 1, 1, 12),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
checksum: 'edited-sha1',
// 30s past createdAt the edit gate fires.
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
);
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
registerFallbackValue(edited);
registerFallbackValue(File('/tmp/fallback'));
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(_) async => 'test',
);
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await SettingsRepository.ensureInitialized(db);
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
await Store.put(StoreKey.deviceId, 'test-device-id');
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
});
tearDownAll(() {
debugDefaultTargetPlatformOverride = null;
});
setUp(() async {
mockUpload = MockUploadRepository();
mockStorage = MockStorageRepository();
mockBackup = MockDriftBackupRepository();
mockConnectivity = MockConnectivityApi();
mockAssetMedia = MockAssetMediaRepository();
mockNativeApi = MockNativeSyncApi();
mockLocalAsset = MockDriftLocalAssetRepository();
mockEditRevert = MockEditRevertService();
mockStack = MockDriftStackRepository();
sut = ForegroundUploadService(
mockUpload,
mockStorage,
mockBackup,
mockConnectivity,
mockAssetMedia,
mockNativeApi,
mockLocalAsset,
mockEditRevert,
mockStack,
);
tmp = await Directory.systemTemp.createTemp('fg_upload_test');
final assetFile = File('${tmp.path}/edited-1.jpg')..writeAsStringSync('edit-bytes');
final baseFile = File('${tmp.path}/edited-1_base.jpg')..writeAsStringSync('base-bytes');
when(() => mockStorage.clearCache()).thenAnswer((_) async {});
when(() => mockConnectivity.getCapabilities()).thenAnswer((_) async => [NetworkCapability.unmetered]);
final entity = MockAssetEntity();
when(() => entity.isLivePhoto).thenReturn(false);
when(() => mockStorage.getAssetEntityForAsset(any())).thenAnswer((_) async => entity);
when(() => mockStorage.isAssetAvailableLocally(any())).thenAnswer((_) async => true);
when(() => mockStorage.getFileForAsset(any())).thenAnswer((_) async => assetFile);
when(() => mockAssetMedia.getOriginalFilename(any())).thenAnswer((_) async => 'edited-1.jpg');
// Not a revert; prior is alive; the edit gate fires with a real base file.
when(() => mockEditRevert.tryHandleRevert(any())).thenAnswer((_) async => null);
when(() => mockStack.priorState(any())).thenAnswer((_) async => PriorState.live);
when(
() => mockNativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => BaseResource(path: baseFile.path, sha1: 'base-sha1'));
when(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
});
tearDown(() async {
if (tmp.existsSync()) {
tmp.deleteSync(recursive: true);
}
});
group('revert short-circuit', () {
test('reports the flipped-to base id and skips the upload', () async {
final reverted = edited.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'old-sha1');
// The service flipped the cover to a base that isn't the stale pre-flip prior.
when(() => mockEditRevert.tryHandleRevert(any())).thenAnswer((_) async => 'base-flip-1');
final successes = <String>[];
await sut.uploadManual([
reverted,
], callbacks: UploadCallbacks(onSuccess: (_, remoteId) => successes.add(remoteId)));
// onSuccess carries the id the service flipped to, not asset.priorRemoteId.
expect(successes, ['base-flip-1']);
verifyNever(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
);
// The revert service does its own bookkeeping; the upload path stamps nothing.
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
});
group('edit pair base failure', () {
test('does not upload the edit or mark synced when the base upload fails', () async {
// Base upload fails; the edit upload should never run.
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((_) async => UploadResult.error(errorMessage: 'boom', statusCode: 500));
await sut.uploadManual([edited]);
// Exactly one upload attempt (the base). The edit must not be uploaded,
// and the asset must stay a candidate (no markSynced).
verify(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: 'baseResource[edited-1]',
),
).called(1);
verifyNever(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: 'asset[edited-1]',
),
);
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
test('uploads the edit with stackParentId and marks synced when the base succeeds', () async {
var uploadCount = 0;
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((invocation) async {
uploadCount++;
// base first base-remote, then the edit edit-remote.
return UploadResult.success(remoteAssetId: uploadCount == 1 ? 'base-remote' : 'edit-remote');
});
await sut.uploadManual([edited]);
// The edit upload carries the base's id as stackParentId.
final captured =
verify(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: captureAny(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: 'asset[edited-1]',
),
).captured.single
as Map<String, String>;
expect(captured['stackParentId'], 'base-remote');
verify(
() => mockLocalAsset.markSynced('edited-1', priorRemoteId: 'edit-remote', syncedChecksum: 'edited-sha1'),
).called(1);
});
});
group('dead stack parent', () {
// Stamped prior is live in the local db, so the edit absorbs into it but the
// server may have already lost the row, answering 400 on the stack attempt.
final stamped = edited.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'old-sha1');
setUp(() {
when(() => mockStack.priorState('prior-1')).thenAnswer((_) async => PriorState.live);
when(() => mockLocalAsset.clearSyncStamps(any())).thenAnswer((_) async {});
});
test('a stale-prior 400 on the main upload clears the sync stamps', () async {
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer(
(_) async => UploadResult.error(
errorMessage: 'Bad request: Cannot stack onto a trashed or missing asset',
statusCode: 400,
),
);
await sut.uploadManual([stamped]);
verify(() => mockLocalAsset.clearSyncStamps('edited-1')).called(1);
});
test('an unrelated upload error leaves the stamps alone', () async {
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((_) async => UploadResult.error(errorMessage: 'boom', statusCode: 500));
await sut.uploadManual([stamped]);
verifyNever(() => mockLocalAsset.clearSyncStamps(any()));
});
});
group('android', () {
test('a successful upload does not stamp the sync columns', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.iOS);
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((_) async => UploadResult.success(remoteAssetId: 'remote-1'));
final successes = <String>[];
await sut.uploadManual([edited], callbacks: UploadCallbacks(onSuccess: (_, remoteId) => successes.add(remoteId)));
expect(successes, ['remote-1']);
// Edit stacking is iOS-only: no base read, no stamps.
verifyNever(() => mockNativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
});
group('live photo edit pair', () {
final liveAsset = LocalAsset(
id: 'live-1',
name: 'live-1.heic',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1, 12),
updatedAt: DateTime(2025, 1, 1, 12),
playbackStyle: AssetPlaybackStyle.livePhoto,
isEdited: false,
checksum: 'edit-still-sha1',
// cloudId so the metadata field appears on the still uploads.
cloudId: 'cloud-live-1',
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
);
late File baseStillFile;
late File baseVideoFile;
late List<String> uploadOrder;
late Map<String, Map<String, String>> fieldsByHop;
late List<String> errors;
setUp(() {
final editStillFile = File('${tmp.path}/live-1.heic')..writeAsStringSync('edit-still-bytes');
final editMotionFile = File('${tmp.path}/live-1_motion.mov')..writeAsStringSync('edit-motion-bytes');
baseStillFile = File('${tmp.path}/live-1_base.heic')..writeAsStringSync('base-still-bytes');
baseVideoFile = File('${tmp.path}/live-1_base.mov')..writeAsStringSync('base-video-bytes');
uploadOrder = [];
fieldsByHop = {};
errors = [];
final entity = MockAssetEntity();
when(() => entity.isLivePhoto).thenReturn(true);
when(() => mockStorage.getAssetEntityForAsset(any())).thenAnswer((_) async => entity);
when(() => mockStorage.getFileForAsset(any())).thenAnswer((_) async => editStillFile);
when(() => mockStorage.getMotionFileForAsset(any())).thenAnswer((_) async => editMotionFile);
when(() => mockAssetMedia.getOriginalFilename(any())).thenAnswer((_) async => 'live-1.heic');
when(
() => mockNativeApi.getBaseLivePhoto(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseLivePhoto(
still: BaseResource(path: baseStillFile.path, sha1: 'original-sha1'),
video: BaseResource(path: baseVideoFile.path, sha1: 'original-video-sha1'),
),
);
});
// Sequences uploads by logContext: records order + a snapshot of the fields,
// succeeds with '<hop>-remote' unless the hop has an override result.
void stubUploads({Map<String, UploadResult> overrides = const {}}) {
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((invocation) async {
final logContext = invocation.namedArguments[#logContext] as String;
final hop = logContext.split('[').first;
uploadOrder.add(logContext);
fieldsByHop[hop] = Map.of(invocation.namedArguments[#fields] as Map<String, String>);
return overrides[hop] ?? UploadResult.success(remoteAssetId: '$hop-remote');
});
}
UploadCallbacks callbacks() => UploadCallbacks(onError: (_, message) => errors.add(message));
test('uploads the base pair, then motion, then the edit with isolated fields', () async {
stubUploads();
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, [
'baseLiveVideo[live-1]',
'baseLiveStill[live-1]',
'livePhotoVideo[live-1]',
'asset[live-1]',
]);
// Both motion videos upload hidden; both stills stay on the timeline. The
// edit's motion never inherits the stack parent resolved just before it.
final editVideoFields = fieldsByHop['livePhotoVideo']!;
expect(editVideoFields['visibility'], 'hidden');
expect(editVideoFields.containsKey('stackParentId'), isFalse);
final baseVideoFields = fieldsByHop['baseLiveVideo']!;
expect(baseVideoFields['visibility'], 'hidden');
expect(baseVideoFields.containsKey('livePhotoVideoId'), isFalse);
expect(baseVideoFields.containsKey('stackParentId'), isFalse);
// The base still pairs with the base video and stays free of the edit's timestamp.
final baseStillFields = fieldsByHop['baseLiveStill']!;
expect(baseStillFields['livePhotoVideoId'], 'baseLiveVideo-remote');
expect(baseStillFields.containsKey('stackParentId'), isFalse);
expect(baseStillFields.containsKey('visibility'), isFalse);
expect(baseStillFields['metadata'], isNotNull);
expect(baseStillFields['metadata'], isNot(contains('adjustmentTime')));
// The edit keeps its own motion id, stacks onto the base still, carries the edit time, stays visible.
final mainFields = fieldsByHop['asset']!;
expect(mainFields['livePhotoVideoId'], 'livePhotoVideo-remote');
expect(mainFields['stackParentId'], 'baseLiveStill-remote');
expect(mainFields.containsKey('visibility'), isFalse);
expect(mainFields['metadata'], contains('adjustmentTime'));
verifyInOrder([
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'baseLiveStill-remote', syncedChecksum: null),
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'asset-remote', syncedChecksum: 'edit-still-sha1'),
]);
expect(errors, isEmpty);
expect(baseStillFile.existsSync(), isFalse);
expect(baseVideoFile.existsSync(), isFalse);
});
test('base video failure stops the chain and keeps the pair a candidate', () async {
stubUploads(overrides: {'baseLiveVideo': UploadResult.error(errorMessage: 'base video boom', statusCode: 500)});
await sut.uploadManual([liveAsset], callbacks: callbacks());
// The pair resolves before any file is materialized, so a failed base burns
// neither a motion upload nor a file read.
expect(uploadOrder, ['baseLiveVideo[live-1]']);
verifyNever(() => mockStorage.isAssetAvailableLocally(any()));
verifyNever(() => mockStorage.getFileForAsset(any()));
verifyNever(() => mockStorage.getMotionFileForAsset(any()));
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
expect(errors, ['base video boom']);
expect(baseStillFile.existsSync(), isFalse);
expect(baseVideoFile.existsSync(), isFalse);
});
test('base still failure after video success stops the chain', () async {
stubUploads(overrides: {'baseLiveStill': UploadResult.error(errorMessage: 'base still boom', statusCode: 500)});
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, ['baseLiveVideo[live-1]', 'baseLiveStill[live-1]']);
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
expect(errors, ['base still boom']);
expect(baseStillFile.existsSync(), isFalse);
expect(baseVideoFile.existsSync(), isFalse);
});
test('degrades to a still-only base when the original has no paired video', () async {
when(
() => mockNativeApi.getBaseLivePhoto(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseLivePhoto(
still: BaseResource(path: baseStillFile.path, sha1: 'original-sha1'),
),
);
stubUploads();
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, ['baseLiveStill[live-1]', 'livePhotoVideo[live-1]', 'asset[live-1]']);
final baseStillFields = fieldsByHop['baseLiveStill']!;
expect(baseStillFields.containsKey('livePhotoVideoId'), isFalse);
// The stack still forms onto the lone base still.
expect(fieldsByHop['asset']!['stackParentId'], 'baseLiveStill-remote');
verify(
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'asset-remote', syncedChecksum: 'edit-still-sha1'),
).called(1);
expect(errors, isEmpty);
expect(baseStillFile.existsSync(), isFalse);
});
test('cancellation on the base hop aborts the run without an error callback', () async {
stubUploads(overrides: {'baseLiveVideo': UploadResult.cancelled()});
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, ['baseLiveVideo[live-1]']);
expect(sut.shouldAbortUpload, isTrue);
expect(errors, isEmpty);
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
test('quota error on the base hop aborts the run and surfaces the error', () async {
stubUploads(
overrides: {'baseLiveVideo': UploadResult.error(errorMessage: 'Quota has been exceeded!', statusCode: 403)},
);
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, ['baseLiveVideo[live-1]']);
expect(sut.shouldAbortUpload, isTrue);
expect(errors, ['Quota has been exceeded!']);
});
test('a trashed prior defers a manual upload, telling the user and materializing nothing', () async {
stubUploads();
when(() => mockStack.priorState('prior-1')).thenAnswer((_) async => PriorState.trashed);
final deferred = liveAsset.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'stale-sha1');
await sut.uploadManual([deferred], callbacks: callbacks());
expect(uploadOrder, isEmpty);
// No localization in tests, so the raw key comes through.
expect(errors, ['upload_deferred_edit_pair']);
expect(sut.shouldAbortUpload, isFalse);
// Resolution happens before any file work, so a defer downloads nothing.
verifyNever(() => mockStorage.isAssetAvailableLocally(any()));
verifyNever(() => mockStorage.getFileForAsset(any()));
verifyNever(() => mockStorage.getMotionFileForAsset(any()));
verifyNever(() => mockStorage.loadFileFromCloud(any(), progressHandler: any(named: 'progressHandler')));
verifyNever(() => mockStorage.loadMotionFileFromCloud(any(), progressHandler: any(named: 'progressHandler')));
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
test('the same defer stays silent on the auto-candidates path', () async {
stubUploads();
when(() => mockStack.priorState('prior-1')).thenAnswer((_) async => PriorState.trashed);
final deferred = liveAsset.copyWith(priorRemoteId: 'prior-1', syncedChecksum: 'stale-sha1');
when(() => mockBackup.getCandidates(any())).thenAnswer((_) async => [deferred]);
await sut.uploadCandidates('user-1', Completer<void>(), callbacks: callbacks());
expect(uploadOrder, isEmpty);
expect(errors, isEmpty);
expect(sut.shouldAbortUpload, isFalse);
verifyNever(() => mockStorage.isAssetAvailableLocally(any()));
verifyNever(() => mockStorage.getFileForAsset(any()));
});
test('cancellation on the motion hop aborts before the main upload', () async {
stubUploads(overrides: {'livePhotoVideo': UploadResult.cancelled()});
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, ['baseLiveVideo[live-1]', 'baseLiveStill[live-1]', 'livePhotoVideo[live-1]']);
expect(sut.shouldAbortUpload, isTrue);
expect(errors, isEmpty);
// Only the base stamp lands; the edit never marks synced.
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: 'edit-still-sha1',
),
);
});
test('quota error on the motion hop aborts and surfaces the error', () async {
stubUploads(
overrides: {'livePhotoVideo': UploadResult.error(errorMessage: 'Quota has been exceeded!', statusCode: 403)},
);
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, ['baseLiveVideo[live-1]', 'baseLiveStill[live-1]', 'livePhotoVideo[live-1]']);
expect(sut.shouldAbortUpload, isTrue);
expect(errors, ['Quota has been exceeded!']);
});
test('a non-quota motion failure falls through to a main upload without livePhotoVideoId', () async {
stubUploads(overrides: {'livePhotoVideo': UploadResult.error(errorMessage: 'motion boom', statusCode: 500)});
await sut.uploadManual([liveAsset], callbacks: callbacks());
expect(uploadOrder, [
'baseLiveVideo[live-1]',
'baseLiveStill[live-1]',
'livePhotoVideo[live-1]',
'asset[live-1]',
]);
expect(sut.shouldAbortUpload, isFalse);
expect(errors, isEmpty);
final mainFields = fieldsByHop['asset']!;
expect(mainFields.containsKey('livePhotoVideoId'), isFalse);
expect(mainFields['stackParentId'], 'baseLiveStill-remote');
verify(
() => mockLocalAsset.markSynced('live-1', priorRemoteId: 'asset-remote', syncedChecksum: 'edit-still-sha1'),
).called(1);
});
});
}
+5
View File
@@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart' as mocktail;
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
void _registerFallbacks() {
mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now()));
@@ -24,6 +25,8 @@ class RepositoryMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final stack = MockDriftStackRepository();
final assetApi = MockAssetApiRepository();
final nativeApi = MockNativeSyncApi();
@@ -35,6 +38,8 @@ class RepositoryMocks {
mocktail.reset(localAlbum);
mocktail.reset(localAsset);
mocktail.reset(trashedAsset);
mocktail.reset(stack);
mocktail.reset(assetApi);
mocktail.reset(nativeApi);
}
}
@@ -0,0 +1,419 @@
import 'dart:async';
import 'dart:io';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/edit_pair.dart';
import 'package:mocktail/mocktail.dart';
import '../mocks.dart';
void main() {
final mocks = RepositoryMocks();
// createdAt fixed; adjustmentTime is what moves a real edit past the gate.
LocalAsset asset({
DateTime? adjustmentTime,
String? priorRemoteId,
String? syncedChecksum,
String? checksum = 'local-sha1',
}) => LocalAsset(
id: 'local-1',
name: 'photo.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1, 12),
updatedAt: DateTime(2025, 1, 1, 12),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
adjustmentTime: adjustmentTime,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
checksum: checksum,
);
BaseResource base(String sha1, {String path = '/tmp/none'}) =>
BaseResource(path: path, sha1: sha1);
String tempFile(String name) {
final dir = Directory.systemTemp.createTempSync('edit_pair_test');
addTearDown(() {
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
});
return (File('${dir.path}/$name')..writeAsStringSync('bytes')).path;
}
void stubBase(BaseResource? result) {
when(
() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => result);
}
Future<EditPairPlan> resolve(LocalAsset asset, {String? ownerId = 'owner-1'}) =>
resolveEditPair(mocks.nativeApi, asset, stackRepository: mocks.stack, ownerId: ownerId);
void stubLive(BaseLivePhoto? result) {
when(
() => mocks.nativeApi.getBaseLivePhoto('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => result);
}
Future<EditPairPlan> resolveLive(LocalAsset asset, {String? ownerId = 'owner-1'}) =>
resolveEditPair(mocks.nativeApi, asset, stackRepository: mocks.stack, ownerId: ownerId, isLivePhoto: true);
setUp(() {
// Default: the prior remote is alive, so absorb is allowed.
when(() => mocks.stack.priorState(any())).thenAnswer((_) async => PriorState.live);
// Default: the base bytes aren't already on the server.
when(
() => mocks.stack.remoteByChecksum(any(), any()),
).thenAnswer((_) async => (state: PriorState.missing, remoteId: null));
});
tearDown(() {
mocks.reset();
});
group('resolveEditPair', () {
test('reuses the prior remote when the asset was already uploaded as an edit', () async {
final plan = await resolve(asset(priorRemoteId: 'remote-edit'));
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-edit'));
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('defers when the prior remote sits in the server trash', () async {
when(() => mocks.stack.priorState('remote-edit')).thenAnswer((_) async => PriorState.trashed);
// Stacking onto a trashed prior would 400; wait for restore or empty-trash.
final plan = await resolve(asset(priorRemoteId: 'remote-edit', adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<DeferEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('resumes onto a missing prior when the chain has not synced back yet', () async {
when(() => mocks.stack.priorState('remote-edit')).thenAnswer((_) async => PriorState.missing);
// syncedChecksum unset = mid-flight chain; the row simply hasn't synced.
final plan = await resolve(asset(priorRemoteId: 'remote-edit', adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-edit'));
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('rebuilds from scratch when a completed prior vanished from the server', () async {
when(() => mocks.stack.priorState('remote-edit')).thenAnswer((_) async => PriorState.missing);
stubBase(base('different-sha1'));
final plan = await resolve(
asset(
priorRemoteId: 'remote-edit',
syncedChecksum: 'synced-sha1',
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
),
);
expect(plan, isA<UploadBaseFirst>());
verify(() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: true)).called(1);
});
test('skips when the vanished prior\'s asset reads as not edited', () async {
when(() => mocks.stack.priorState('remote-edit')).thenAnswer((_) async => PriorState.missing);
// Fresh resolution from scratch: no adjustment nothing to stack.
final plan = await resolve(asset(priorRemoteId: 'remote-edit', syncedChecksum: 'synced-sha1'));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('defers when the prior lookup itself fails', () async {
when(() => mocks.stack.priorState('remote-edit')).thenThrow(Exception('db error'));
final plan = await resolve(asset(priorRemoteId: 'remote-edit'));
expect(plan, isA<DeferEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips a photo that was never adjusted without touching native', () async {
final plan = await resolve(asset(adjustmentTime: null));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips a capture-time style (adjustment within the 2s window)', () async {
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 1)));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips at exactly the 2s boundary (tolerance is exclusive)', () async {
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 2)));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('checks the original just past the 2s boundary', () async {
stubBase(base('different-sha1'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 3)));
expect(plan, isA<UploadBaseFirst>());
verify(() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: true)).called(1);
});
test('uploads the original first when a real edit moved the timestamp', () async {
stubBase(base('different-sha1'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<UploadBaseFirst>());
verify(() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: true)).called(1);
});
test('skips when the original is positively gone (native returned null)', () async {
stubBase(null);
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<NoEditPair>());
});
test('skips when the original bytes match the asset (auto-HDR, nothing to stack)', () async {
final basePath = tempFile('base.jpg');
stubBase(base('local-sha1', path: basePath));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<NoEditPair>());
// The temp copy is dropped, not leaked.
expect(File(basePath).existsSync(), isFalse);
});
test('defers when reading the original throws (unreadable plist, iCloud hiccup)', () async {
when(
() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenThrow(Exception('boom'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<DeferEditPair>());
});
test('defers when the base read never replies (platform-channel hang)', () {
fakeAsync((time) {
when(
() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) => Completer<BaseResource?>().future);
EditPairPlan? plan;
unawaited(resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30))).then((p) => plan = p));
time.elapse(const Duration(minutes: 31));
expect(plan, isA<DeferEditPair>());
});
});
test('absorbs onto a live remote that already has the base bytes', () async {
final basePath = tempFile('base.jpg');
stubBase(base('base-sha1', path: basePath));
when(
() => mocks.stack.remoteByChecksum('base-sha1', 'owner-1'),
).thenAnswer((_) async => (state: PriorState.live, remoteId: 'remote-base'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-base'));
// No point re-uploading bytes the server has; the temp copy is dropped.
expect(File(basePath).existsSync(), isFalse);
});
test('defers while the server copy of the base sits in the trash', () async {
final basePath = tempFile('base.jpg');
stubBase(base('base-sha1', path: basePath));
when(
() => mocks.stack.remoteByChecksum('base-sha1', 'owner-1'),
).thenAnswer((_) async => (state: PriorState.trashed, remoteId: 'remote-base'));
// Uploading would dedupe onto the trashed row and the stack would 400.
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<DeferEditPair>());
expect(File(basePath).existsSync(), isFalse);
});
test('defers when the duplicate-base lookup itself fails', () async {
final basePath = tempFile('base.jpg');
stubBase(base('base-sha1', path: basePath));
when(() => mocks.stack.remoteByChecksum(any(), any())).thenThrow(Exception('db error'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<DeferEditPair>());
expect(File(basePath).existsSync(), isFalse);
});
test('skips the duplicate-base check when no owner is known', () async {
stubBase(base('different-sha1'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)), ownerId: null);
expect(plan, isA<UploadBaseFirst>());
verifyNever(() => mocks.stack.remoteByChecksum(any(), any()));
});
});
group('resolveEditPair live photos', () {
test('uploads the original pair when a real edit moved the timestamp', () async {
stubLive(BaseLivePhoto(still: base('still-sha1'), video: base('video-sha1')));
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(
plan,
isA<UploadBaseLivePhotoFirst>()
.having((p) => p.still.sha1, 'still', 'still-sha1')
.having((p) => p.video?.sha1, 'video', 'video-sha1'),
);
verify(() => mocks.nativeApi.getBaseLivePhoto('local-1', allowNetworkAccess: true)).called(1);
});
test('degrades to a still-only parent when the original has no paired video', () async {
stubLive(BaseLivePhoto(still: base('still-sha1')));
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<UploadBaseLivePhotoFirst>().having((p) => p.video, 'video', isNull));
});
test('skips when the original pair is positively gone (native returned null)', () async {
stubLive(null);
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<NoEditPair>());
});
test('skips a video-only edit whose still bytes are unchanged (no self-stack)', () async {
// The base still matches the current asset, so it would dedupe to the edit itself.
final stillPath = tempFile('still.heic');
final videoPath = tempFile('paired.mov');
stubLive(
BaseLivePhoto(
still: base('local-sha1', path: stillPath),
video: base('video-sha1', path: videoPath),
),
);
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<NoEditPair>());
// Both temp copies are dropped, not leaked.
expect(File(stillPath).existsSync(), isFalse);
expect(File(videoPath).existsSync(), isFalse);
});
test('defers when reading the original pair throws', () async {
when(
() => mocks.nativeApi.getBaseLivePhoto('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenThrow(Exception('boom'));
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<DeferEditPair>());
});
test('does not touch native for a live photo inside the 2s style window', () async {
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 1)));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseLivePhoto(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('defers a live photo whose prior remote sits in the server trash', () async {
when(() => mocks.stack.priorState('remote-edit')).thenAnswer((_) async => PriorState.trashed);
final plan = await resolveLive(
asset(priorRemoteId: 'remote-edit', adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)),
);
expect(plan, isA<DeferEditPair>());
verifyNever(() => mocks.nativeApi.getBaseLivePhoto(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('rebuilds the pair when a completed prior vanished from the server', () async {
when(() => mocks.stack.priorState('remote-edit')).thenAnswer((_) async => PriorState.missing);
stubLive(BaseLivePhoto(still: base('still-sha1'), video: base('video-sha1')));
final plan = await resolveLive(
asset(
priorRemoteId: 'remote-edit',
syncedChecksum: 'synced-sha1',
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
),
);
expect(plan, isA<UploadBaseLivePhotoFirst>());
verify(() => mocks.nativeApi.getBaseLivePhoto('local-1', allowNetworkAccess: true)).called(1);
});
test('absorbs the prior remote for a re-edited live photo', () async {
final plan = await resolveLive(
asset(priorRemoteId: 'remote-edit', adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)),
);
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-edit'));
verifyNever(() => mocks.nativeApi.getBaseLivePhoto(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('absorbs onto a live remote that already has the base still', () async {
final stillPath = tempFile('still.heic');
final videoPath = tempFile('paired.mov');
stubLive(
BaseLivePhoto(
still: base('still-sha1', path: stillPath),
video: base('video-sha1', path: videoPath),
),
);
when(
() => mocks.stack.remoteByChecksum('still-sha1', 'owner-1'),
).thenAnswer((_) async => (state: PriorState.live, remoteId: 'remote-base'));
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-base'));
// Both temp copies are dropped, not leaked.
expect(File(stillPath).existsSync(), isFalse);
expect(File(videoPath).existsSync(), isFalse);
});
test('defers the pair while the server copy of the base still sits in the trash', () async {
final stillPath = tempFile('still.heic');
final videoPath = tempFile('paired.mov');
stubLive(
BaseLivePhoto(
still: base('still-sha1', path: stillPath),
video: base('video-sha1', path: videoPath),
),
);
when(
() => mocks.stack.remoteByChecksum('still-sha1', 'owner-1'),
).thenAnswer((_) async => (state: PriorState.trashed, remoteId: 'remote-base'));
final plan = await resolveLive(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<DeferEditPair>());
expect(File(stillPath).existsSync(), isFalse);
expect(File(videoPath).existsSync(), isFalse);
});
});
}
@@ -0,0 +1,221 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
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 = RepositoryMocks();
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,
);
// Revert detected and the stack resolved: edit-state reads notEdited, the prior
// remote sits in stack-1 whose base is remote-base.
void stubRevertLookups() {
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');
}
setUp(() {
sut = EditRevertService(
nativeSyncApi: mocks.nativeApi,
stackRepository: mocks.stack,
localAssetRepository: mocks.localAsset,
assetApiRepository: mocks.assetApi,
);
});
tearDown(() {
mocks.reset();
});
group('tryHandleRevert', () {
test('returns null when the asset was never uploaded as an edit', () async {
expect(await sut.tryHandleRevert(asset(priorRemoteId: null)), isNull);
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('returns null (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')), isNull);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns null 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')), isNull);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns null when the edit-state probe hangs past 30s', () {
fakeAsync((time) {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) => Completer<EditState>().future);
String? handled;
var completed = false;
unawaited(
sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')).then((r) {
handled = r;
completed = true;
}),
);
time.elapse(const Duration(seconds: 30));
expect(completed, isTrue);
expect(handled, isNull);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
});
test('returns null when the edit-state probe throws', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenThrow(Exception('plist read failed'));
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isNull);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns null when the stack lookup throws', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenThrow(Exception('db error'));
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isNull);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('returns null when the server primary flip throws', () async {
stubRevertLookups();
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenThrow(Exception('500'));
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isNull);
// No local writes when the server never flipped.
verifyNever(() => mocks.stack.setPrimary(any(), any()));
verifyNever(
() => mocks.localAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
test('returns the base id when the server flip succeeds but the local writes throw', () async {
stubRevertLookups();
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenThrow(Exception('db locked'));
// The server flip is what handles the revert; local state heals on next sync.
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), 'remote-base');
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
verifyNever(
() => mocks.localAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
test('returns null 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')), isNull);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('returns null 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')), isNull);
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 {
stubRevertLookups();
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: 'reverted-sha1'),
).thenAnswer((_) async {});
// Success reports the base the cover flipped back to.
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), 'remote-base');
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
// The synced checksum is exactly the reverted render's checksum.
verify(
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'remote-base', syncedChecksum: 'reverted-sha1'),
).called(1);
// Nothing is trashed or unstacked; every edit stays in the stack.
verifyNever(() => mocks.assetApi.delete(any(), any()));
verifyNever(() => mocks.assetApi.unStack(any()));
});
test('handles a revert on a live-photo-shaped asset the same way', () async {
// Same flow regardless of media shape; the service never branches on type.
final live = LocalAsset(
id: 'local-1',
name: 'live.heic',
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2),
durationMs: 2800,
playbackStyle: AssetPlaybackStyle.livePhoto,
isEdited: false,
priorRemoteId: 'remote-edit',
checksum: 'reverted-sha1',
);
stubRevertLookups();
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: 'reverted-sha1'),
).thenAnswer((_) async {});
expect(await sut.tryHandleRevert(live), 'remote-base');
verify(
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'remote-base', syncedChecksum: 'reverted-sha1'),
).called(1);
});
});
}
@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.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/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
@@ -18,6 +20,8 @@ void main() {
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
@@ -110,6 +114,8 @@ void main() {
nativeSyncApi: mocks.nativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
);
final album = LocalAlbumFactory.create();
@@ -183,5 +189,74 @@ void main() {
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);
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()).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('reconciles even when nothing was hashed this cycle (offline-flip retry)', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final album = LocalAlbumFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(() => mocks.stack.findRevertReconcileTargets()).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verify(() => mocks.stack.findRevertReconcileTargets()).called(1);
});
test('does not reconcile on a non-iOS platform', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
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());
});
});
});
}
+6
View File
@@ -17079,6 +17079,12 @@
"format": "binary",
"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": {
"$ref": "#/components/schemas/AssetVisibility"
}
+2
View File
@@ -648,6 +648,8 @@ export type AssetMediaCreateDto = {
metadata?: AssetMetadataUpsertItemDto[];
/** Sidecar file data */
sidecarData?: Blob;
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
stackParentId?: string;
visibility?: AssetVisibility;
};
export type AssetMediaResponseDto = {
+4
View File
@@ -48,6 +48,10 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
isFavorite: stringToBool.optional().describe('Mark as favorite'),
visibility: AssetVisibilitySchema.optional(),
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'),
[UploadFieldName.SIDECAR_DATA]: z
.any()
+58 -1
View File
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
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 {
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] })
async delete(id: string): Promise<void> {
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
@@ -346,6 +346,28 @@ describe(AssetMediaService.name, () => {
);
});
it('should emit AssetCreate exactly once and keep the file for a plain upload', 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,
};
mocks.asset.create.mockResolvedValue(assetEntity);
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
id: 'id_1',
status: AssetMediaStatus.CREATED,
});
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', { asset: assetEntity, file });
expect(mocks.job.queue).not.toHaveBeenCalledWith(expect.objectContaining({ name: JobName.FileDelete }));
});
it('should handle a duplicate', async () => {
const file = {
uuid: 'random-uuid',
@@ -417,6 +439,180 @@ describe(AssetMediaService.name, () => {
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' }),
file: expect.objectContaining({ originalPath: file.originalPath }),
});
});
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 not link a duplicate that resolves to the stack parent itself', 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(parent.id);
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: parent.id,
status: AssetMediaStatus.DUPLICATE,
});
expect(mocks.stack.linkAsset).not.toHaveBeenCalled();
});
it('should keep the created asset when stack linking fails after create', 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.mockRejectedValue(new Error('database connection lost'));
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'id_1',
status: AssetMediaStatus.CREATED,
});
// the original file must not be deleted - the asset row already exists
expect(mocks.job.queue).not.toHaveBeenCalledWith(expect.objectContaining({ name: JobName.FileDelete }));
});
it('should keep the created asset when the AssetCreate emit fails', 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 });
mocks.event.emit.mockImplementation((...args) =>
args[0] === 'AssetCreate' ? Promise.reject(new Error('emit failed')) : Promise.resolve(),
);
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'id_1',
status: AssetMediaStatus.CREATED,
});
expect(mocks.job.queue).not.toHaveBeenCalledWith(expect.objectContaining({ name: JobName.FileDelete }));
});
it('should clean up the file when an error occurs before create', 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,
};
await expect(
sut.uploadAsset(
{ ...authStub.admin, user: { ...authStub.admin.user, quotaSizeInBytes: 42, quotaUsageInBytes: 1 } },
createDto,
file,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [file.originalPath, undefined] },
});
});
it('should hide the linked motion asset', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const asset = AssetFactory.create();
+141 -65
View File
@@ -140,88 +140,76 @@ export class AssetMediaService extends BaseService {
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) {
await onBeforeLink(
{ asset: this.assetRepository, event: this.eventRepository },
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
);
}
const asset = await this.assetRepository.create({
ownerId: auth.user.id,
libraryId: null,
checksum: file.checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
type: mimeTypes.assetType(file.originalPath),
isFavorite: dto.isFavorite,
duration: dto.duration || null,
visibility: dto.visibility ?? AssetVisibility.Timeline,
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: dto.filename || file.originalName,
});
if (dto.metadata?.length) {
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
}
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
});
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({
exif: { assetId: asset.id, fileSizeInByte: file.size },
lockedPropertiesBehavior: 'override',
});
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, asset.id);
}
await this.eventRepository.emit('AssetCreate', { asset, file });
if (dto.stackParentId) {
// emit AssetCreate with the populated stackId so clients don't briefly
// see the asset as standalone
try {
const linkResult = await this.linkToStackParent(auth.user.id, asset.id, dto.stackParentId);
await this.eventRepository.emit('AssetCreate', {
asset: linkResult ? { ...asset, stackId: linkResult.stackId } : asset,
file,
});
} catch (error: any) {
// the asset exists at this point - falling through to handleUploadError
// would queue a FileDelete for its original file and orphan the row.
// Linking and events degrade gracefully, so log and return the id.
this.logger.error(`Post-create stack handling failed for asset ${asset.id}: ${error}`, error?.stack);
}
} else {
await this.eventRepository.emit('AssetCreate', { asset, file });
}
return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) {
// clean up files
await this.jobRepository.queue({
name: JobName.FileDelete,
data: { files: [file.originalPath, sidecarFile?.originalPath] },
});
// handle duplicates with a success response
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
this.logger.error(`Error uploading file ${error}`, error?.stack);
throw error;
return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
}
}
private async linkToStackParent(
ownerId: string,
newAssetId: string,
parentId: string,
): Promise<{ stackId: string; created: boolean } | null> {
if (newAssetId === parentId) {
// duplicate upload resolving to the parent itself - linking would create
// a one-member stack
return 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> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -347,6 +335,94 @@ export class AssetMediaService extends BaseService {
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
}
private async handleUploadError(
error: any,
auth: AuthDto,
file: UploadFile,
sidecarFile?: UploadFile,
stackParentId?: string,
): Promise<AssetMediaResponseDto> {
// clean up files
await this.jobRepository.queue({
name: JobName.FileDelete,
data: { files: [file.originalPath, sidecarFile?.originalPath] },
});
// handle duplicates with a success response
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
if (auth.sharedLink) {
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. A link failure shouldn't
// turn the duplicate response into a 500 - the asset exists either way.
try {
await this.linkToStackParent(auth.user.id, duplicateId, stackParentId);
} catch (error: any) {
this.logger.error(`Failed to stack duplicate ${duplicateId}: ${error}`, error?.stack);
}
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
this.logger.error(`Error uploading file ${error}`, error?.stack);
throw error;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,
libraryId: null,
checksum: file.checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
type: mimeTypes.assetType(file.originalPath),
isFavorite: dto.isFavorite,
duration: dto.duration || null,
visibility: dto.visibility ?? AssetVisibility.Timeline,
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: dto.filename || file.originalName,
});
if (dto.metadata?.length) {
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
}
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
});
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({
exif: { assetId: asset.id, fileSizeInByte: file.size },
lockedPropertiesBehavior: 'override',
});
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
return asset;
}
private requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!');
+6
View File
@@ -79,6 +79,12 @@ export const isAssetChecksumConstraint = (error: unknown) =>
export const isVideoStreamSessionPkConstraint = (error: unknown) =>
(error as PostgresError)?.constraint_name === VIDEO_STREAM_SESSION_PK_CONSTRAINT;
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>) {
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
}