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