mirror of
https://github.com/immich-app/immich.git
synced 2026-06-12 19:11:52 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e6d175735 | |||
| 9172397b41 |
@@ -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",
|
||||
|
||||
+192
-11
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+3646
File diff suppressed because it is too large
Load Diff
-18
@@ -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]
|
||||
}
|
||||
|
||||
Generated
+170
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+155
-3
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+13
-3
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
+10114
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user