mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:52:32 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d9183ab44 | |||
| fd7ddfef54 | |||
| 0975b1599c | |||
| 78ac0ade01 |
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
|
|||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
import app.alextran.immich.images.RemoteImagesImpl
|
import app.alextran.immich.images.RemoteImagesImpl
|
||||||
|
import app.alextran.immich.permission.PermissionApi
|
||||||
|
import app.alextran.immich.permission.PermissionApiImpl
|
||||||
import app.alextran.immich.sync.NativeSyncApi
|
import app.alextran.immich.sync.NativeSyncApi
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||||
@@ -44,7 +46,9 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
NativeSyncApiImpl30(ctx)
|
NativeSyncApiImpl30(ctx)
|
||||||
}
|
}
|
||||||
|
val permissionApiImpl = PermissionApiImpl(ctx)
|
||||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||||
|
PermissionApi.setUp(messenger, permissionApiImpl)
|
||||||
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
||||||
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
|
|
||||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||||
|
flutterEngine.plugins.add(permissionApiImpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||||
@@ -60,6 +65,8 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||||
nativeApi?.detachFromEngine()
|
nativeApi?.detachFromEngine()
|
||||||
|
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||||
|
permissionApi?.detachFromEngine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package app.alextran.immich.permission
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
|
|
||||||
|
class ManageMediaPermissionDelegate(
|
||||||
|
context: Context,
|
||||||
|
private val requestCode: Int = 1003,
|
||||||
|
) : PluginRegistry.ActivityResultListener {
|
||||||
|
private val ctx = context.applicationContext
|
||||||
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||||
|
|
||||||
|
fun hasManageMediaPermission(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaStore.canManageMedia(ctx)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
if (hasManageMediaPermission()) {
|
||||||
|
callback(Result.success(true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openManageMediaPermissionSettings(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
openManageMediaPermissionSettings(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
callback(Result.success(false))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = activityBinding?.activity
|
||||||
|
if (activity == null) {
|
||||||
|
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingResult = callback
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
|
||||||
|
data = "package:${activity.packageName}".toUri()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
activity.startActivityForResult(intent, requestCode)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingResult = null
|
||||||
|
callback(
|
||||||
|
Result.failure(
|
||||||
|
FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivity() {
|
||||||
|
failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
|
||||||
|
activityBinding?.removeActivityResultListener(this)
|
||||||
|
activityBinding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode == this.requestCode) {
|
||||||
|
val callback = pendingResult
|
||||||
|
pendingResult = null
|
||||||
|
callback?.invoke(Result.success(hasManageMediaPermission()))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failPending(code: String, message: String) {
|
||||||
|
val callback = pendingResult ?: return
|
||||||
|
pendingResult = null
|
||||||
|
callback(Result.failure(FlutterError(code, message, null)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
|
package app.alextran.immich.permission
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.plugin.common.BasicMessageChannel
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MessageCodec
|
||||||
|
import io.flutter.plugin.common.StandardMethodCodec
|
||||||
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
private object PermissionApiPigeonUtils {
|
||||||
|
|
||||||
|
fun wrapResult(result: Any?): List<Any?> {
|
||||||
|
return listOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wrapError(exception: Throwable): List<Any?> {
|
||||||
|
return if (exception is FlutterError) {
|
||||||
|
listOf(
|
||||||
|
exception.code,
|
||||||
|
exception.message,
|
||||||
|
exception.details
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
exception.javaClass.simpleName,
|
||||||
|
exception.toString(),
|
||||||
|
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||||
|
* @property code The error code.
|
||||||
|
* @property message The error message.
|
||||||
|
* @property details The error details. Must be a datatype supported by the api codec.
|
||||||
|
*/
|
||||||
|
class FlutterError (
|
||||||
|
val code: String,
|
||||||
|
override val message: String? = null,
|
||||||
|
val details: Any? = null
|
||||||
|
) : RuntimeException()
|
||||||
|
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||||
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
return super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
|
super.writeValue(stream, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface PermissionApi {
|
||||||
|
fun hasManageMediaPermission(): Boolean
|
||||||
|
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||||
|
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by PermissionApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
PermissionApiPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.hasManageMediaPermission())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
PermissionApiPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
api.requestManageMediaPermission{ result: Result<Boolean> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
api.manageMediaPermission{ result: Result<Boolean> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package app.alextran.immich.permission
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
|
||||||
|
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
|
||||||
|
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
|
||||||
|
|
||||||
|
override fun hasManageMediaPermission(): Boolean =
|
||||||
|
manageMediaPermissionDelegate.hasManageMediaPermission()
|
||||||
|
|
||||||
|
override fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package app.alextran.immich.sync
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
|
|
||||||
|
class MediaTrashDelegate(
|
||||||
|
context: Context,
|
||||||
|
private val trashRequestCode: Int = 1002,
|
||||||
|
) : PluginRegistry.ActivityResultListener {
|
||||||
|
private val ctx = context.applicationContext
|
||||||
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun hasManageMediaPermission(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaStore.canManageMedia(ctx)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
|
||||||
|
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val id = mediaId.toLongOrNull()
|
||||||
|
if (id == null) {
|
||||||
|
callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInTrash(id)) {
|
||||||
|
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun restoreUri(
|
||||||
|
contentUri: Uri,
|
||||||
|
callback: (Result<Boolean>) -> Unit,
|
||||||
|
) {
|
||||||
|
val activity = activityBinding?.activity
|
||||||
|
if (activity == null) {
|
||||||
|
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
|
||||||
|
pendingResult = callback
|
||||||
|
activity.startIntentSenderForResult(
|
||||||
|
pendingIntent.intentSender,
|
||||||
|
trashRequestCode,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingResult = null
|
||||||
|
callback(
|
||||||
|
Result.failure(
|
||||||
|
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun isInTrash(id: Long): Boolean {
|
||||||
|
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
val args = Bundle().apply {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||||
|
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||||
|
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||||
|
}
|
||||||
|
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||||
|
?.use { it.moveToFirst() } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contentUriForType(type: Int): Uri =
|
||||||
|
when (type) {
|
||||||
|
// Same order as AssetType from Dart.
|
||||||
|
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivity() {
|
||||||
|
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
|
||||||
|
activityBinding?.removeActivityResultListener(this)
|
||||||
|
activityBinding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode == trashRequestCode) {
|
||||||
|
val callback = pendingResult
|
||||||
|
pendingResult = null
|
||||||
|
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failPending(code: String, message: String) {
|
||||||
|
val callback = pendingResult ?: return
|
||||||
|
pendingResult = null
|
||||||
|
callback(Result.failure(FlutterError(code, message, null)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
-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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,7 +629,10 @@ interface NativeSyncApi {
|
|||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
|
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||||
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. */
|
||||||
@@ -747,6 +826,27 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val mediaIdArg = args[0] as String
|
||||||
|
val typeArg = args[1] as Long
|
||||||
|
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
|
||||||
|
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 {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
@@ -764,6 +864,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
|
|||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -39,10 +41,11 @@ sealed class AssetResult {
|
|||||||
private const val TAG = "NativeSyncApiImplBase"
|
private const val TAG = "NativeSyncApiImplBase"
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
private var hashTask: Job? = null
|
private var hashTask: Job? = null
|
||||||
|
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||||
@@ -448,9 +451,39 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
hashTask = null
|
hashTask = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||||
|
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
mediaTrashDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
mediaTrashDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
|
||||||
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||||
@Suppress("unused", "UNUSED_PARAMETER")
|
@Suppress("unused", "UNUSED_PARAMETER")
|
||||||
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3411
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@
|
|||||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
||||||
|
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
|
||||||
|
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
|
||||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
||||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
@@ -105,6 +107,8 @@
|
|||||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
||||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
||||||
|
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
|
||||||
|
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
|
||||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||||
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -283,6 +287,7 @@
|
|||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
|
B2EE00052E72CA15008B6CA7 /* Permission */,
|
||||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
||||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@@ -317,6 +322,15 @@
|
|||||||
path = Connectivity;
|
path = Connectivity;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B2EE00052E72CA15008B6CA7 /* Permission */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
|
||||||
|
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
|
||||||
|
);
|
||||||
|
path = Permission;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -619,6 +633,8 @@
|
|||||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
||||||
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||||
|
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
|
||||||
|
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
|
||||||
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import native_video_player
|
|||||||
|
|
||||||
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
||||||
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
|
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
|
||||||
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
||||||
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import Flutter
|
||||||
|
#elseif os(macOS)
|
||||||
|
import FlutterMacOS
|
||||||
|
#else
|
||||||
|
#error("Unsupported platform.")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||||
|
return [result]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wrapError(_ error: Any) -> [Any?] {
|
||||||
|
if let pigeonError = error as? PigeonError {
|
||||||
|
return [
|
||||||
|
pigeonError.code,
|
||||||
|
pigeonError.message,
|
||||||
|
pigeonError.details,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if let flutterError = error as? FlutterError {
|
||||||
|
return [
|
||||||
|
flutterError.code,
|
||||||
|
flutterError.message,
|
||||||
|
flutterError.details,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"\(error)",
|
||||||
|
"\(Swift.type(of: error))",
|
||||||
|
"Stacktrace: \(Thread.callStackSymbols)",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isNullish(_ value: Any?) -> Bool {
|
||||||
|
return value is NSNull || value == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||||
|
if value is NSNull { return nil }
|
||||||
|
return value as! T?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
|
protocol PermissionApi {
|
||||||
|
func hasManageMediaPermission() throws -> Bool
|
||||||
|
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||||
|
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
|
class PermissionApiSetup {
|
||||||
|
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
|
||||||
|
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
|
||||||
|
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||||
|
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||||
|
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
let result = try api.hasManageMediaPermission()
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasManageMediaPermissionChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||||
|
api.requestManageMediaPermission { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requestManageMediaPermissionChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
manageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||||
|
api.manageMediaPermission { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
manageMediaPermissionChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class PermissionApiImpl: PermissionApi {
|
||||||
|
func hasManageMediaPermission() throws -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||||
|
completion(.success(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||||
|
completion(.success(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+137
-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)
|
||||||
}
|
}
|
||||||
@@ -537,7 +603,10 @@ protocol NativeSyncApi {
|
|||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
|
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||||
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`.
|
||||||
@@ -721,6 +790,24 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let mediaIdArg = args[0] as! String
|
||||||
|
let typeArg = args[1] as! Int64
|
||||||
|
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
restoreFromTrashByIdChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
@@ -738,5 +825,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
|
||||||
@@ -383,6 +384,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||||
|
completion(.success(false))
|
||||||
|
}
|
||||||
|
|
||||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||||
// Ensure to actually getting all assets for the Recents album
|
// Ensure to actually getting all assets for the Recents album
|
||||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||||
@@ -415,4 +420,169 @@ 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).
|
||||||
|
// adjustmentRenderTypes is the signal: a real edit moves it off the baseline, while a
|
||||||
|
// plain capture, a Photographic Style, and a reverted edit all sit at the baseline. The
|
||||||
|
// editor id is NOT reliable: com.apple.camera authors both styles and some real edits
|
||||||
|
// (e.g. changing the Photographic Style after capture), so we key off the render types
|
||||||
|
// alone. Cleanup and object-removal write AdjustmentsSecondary.data, which we count as
|
||||||
|
// edited. unknown = couldn't read the plist (offloaded, no network).
|
||||||
|
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
|
||||||
|
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
|
||||||
|
return .edited
|
||||||
|
}
|
||||||
|
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
|
||||||
|
return .notEdited
|
||||||
|
}
|
||||||
|
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
|
||||||
|
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
|
||||||
|
else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
|
||||||
|
let isUserEdit = renderTypes != nil && renderTypes != kNoEditRenderTypes
|
||||||
|
return isUserEdit ? .edited : .notEdited
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -23,29 +23,29 @@ class LocalSyncService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final LocalFilesManagerRepository _localFilesManager;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final StorageRepository _storageRepository;
|
final IPermissionRepository _permissionRepository;
|
||||||
final Logger _log = Logger("DeviceSyncService");
|
final Logger _log = Logger("DeviceSyncService");
|
||||||
|
|
||||||
LocalSyncService({
|
LocalSyncService({
|
||||||
required DriftLocalAlbumRepository localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required LocalFilesManagerRepository localFilesManager,
|
required AssetMediaRepository assetMediaRepository,
|
||||||
required StorageRepository storageRepository,
|
required IPermissionRepository permissionRepository,
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_localFilesManager = localFilesManager,
|
_assetMediaRepository = assetMediaRepository,
|
||||||
_storageRepository = storageRepository,
|
_permissionRepository = permissionRepository,
|
||||||
_nativeSyncApi = nativeSyncApi;
|
_nativeSyncApi = nativeSyncApi;
|
||||||
|
|
||||||
Future<void> sync({bool full = false}) async {
|
Future<void> sync({bool full = false}) async {
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
await _syncTrashedAssets();
|
await _syncTrashedAssets();
|
||||||
} else {
|
} else {
|
||||||
@@ -373,7 +373,7 @@ class LocalSyncService {
|
|||||||
|
|
||||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||||
if (assetsToRestore.isNotEmpty) {
|
if (assetsToRestore.isNotEmpty) {
|
||||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||||
} else {
|
} else {
|
||||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||||
@@ -381,15 +381,15 @@ class LocalSyncService {
|
|||||||
|
|
||||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||||
if (localAssetsToTrash.isNotEmpty) {
|
if (localAssetsToTrash.isNotEmpty) {
|
||||||
final mediaUrls = await Future.wait(
|
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||||
localAssetsToTrash.values
|
_log.info("Moving to trash ${localIds.join(", ")} assets");
|
||||||
.expand((e) => e)
|
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
if (movedIds.isNotEmpty) {
|
||||||
);
|
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||||
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
)..removeWhere((_, assets) => assets.isEmpty);
|
||||||
if (result) {
|
|
||||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.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/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -34,8 +34,8 @@ class SyncStreamService {
|
|||||||
final SyncStreamRepository _syncStreamRepository;
|
final SyncStreamRepository _syncStreamRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final LocalFilesManagerRepository _localFilesManager;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final StorageRepository _storageRepository;
|
final IPermissionRepository _permissionRepository;
|
||||||
final SyncMigrationRepository _syncMigrationRepository;
|
final SyncMigrationRepository _syncMigrationRepository;
|
||||||
final ApiService _api;
|
final ApiService _api;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
@@ -45,8 +45,8 @@ class SyncStreamService {
|
|||||||
required SyncStreamRepository syncStreamRepository,
|
required SyncStreamRepository syncStreamRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required LocalFilesManagerRepository localFilesManager,
|
required AssetMediaRepository assetMediaRepository,
|
||||||
required StorageRepository storageRepository,
|
required IPermissionRepository permissionRepository,
|
||||||
required SyncMigrationRepository syncMigrationRepository,
|
required SyncMigrationRepository syncMigrationRepository,
|
||||||
required ApiService api,
|
required ApiService api,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
@@ -54,8 +54,8 @@ class SyncStreamService {
|
|||||||
_syncStreamRepository = syncStreamRepository,
|
_syncStreamRepository = syncStreamRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_localFilesManager = localFilesManager,
|
_assetMediaRepository = assetMediaRepository,
|
||||||
_storageRepository = storageRepository,
|
_permissionRepository = permissionRepository,
|
||||||
_syncMigrationRepository = syncMigrationRepository,
|
_syncMigrationRepository = syncMigrationRepository,
|
||||||
_api = api,
|
_api = api,
|
||||||
_cancelChecker = cancelChecker;
|
_cancelChecker = cancelChecker;
|
||||||
@@ -500,22 +500,22 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||||
final mediaUrls = await Future.wait(
|
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||||
localAssetsToTrash.values
|
_logger.info("Moving to trash ${localIds.join(", ")} assets");
|
||||||
.expand((e) => e)
|
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
if (movedIds.isNotEmpty) {
|
||||||
);
|
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
)..removeWhere((_, assets) => assets.isEmpty);
|
||||||
if (result) {
|
|
||||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyRemoteRestoreToLocal() async {
|
Future<void> _applyRemoteRestoreToLocal() async {
|
||||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||||
if (assetsToRestore.isNotEmpty) {
|
if (assetsToRestore.isNotEmpty) {
|
||||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||||
} else {
|
} else {
|
||||||
_logger.info("No remote assets found for restoration");
|
_logger.info("No remote assets found for restoration");
|
||||||
@@ -523,7 +523,7 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -533,7 +533,7 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ class BackgroundSyncManager {
|
|||||||
} on CanceledError {
|
} on CanceledError {
|
||||||
// Ignore cancellation errors
|
// Ignore cancellation errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop the local-sync and hash slots too. The revert reconcile runs in the hash
|
||||||
|
// task and shouldn't outlive the session.
|
||||||
|
await cancelLocal();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelLocal() async {
|
Future<void> cancelLocal() async {
|
||||||
@@ -186,6 +190,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;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
|||||||
|
|
||||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
|
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
|
||||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
|
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
|
||||||
|
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)')
|
||||||
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||||
const LocalAssetEntity();
|
const LocalAssetEntity();
|
||||||
|
|
||||||
@@ -27,6 +28,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 +60,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
|||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
cloudId: iCloudId,
|
cloudId: iCloudId,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
priorRemoteId: priorRemoteId,
|
||||||
|
syncedChecksum: syncedChecksum,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-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();
|
||||||
}
|
}
|
||||||
@@ -1348,3 +1496,7 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
|
|||||||
'idx_local_asset_cloud_id',
|
'idx_local_asset_cloud_id',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||||
);
|
);
|
||||||
|
i0.Index get idxLocalAssetPriorRemoteId => i0.Index(
|
||||||
|
'idx_local_asset_prior_remote_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||||
|
);
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import 'local_album_asset.entity.dart';
|
|||||||
mergedAsset:
|
mergedAsset:
|
||||||
SELECT
|
SELECT
|
||||||
rae.id as remote_id,
|
rae.id as remote_id,
|
||||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
|
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
|
||||||
|
-- edit re-encodes to fresh bytes so the checksum no longer matches, but its
|
||||||
|
-- prior_remote_id still points at this remote, so fall back to that.
|
||||||
|
COALESCE(
|
||||||
|
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||||
|
(SELECT lae.id FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||||
|
) as local_id,
|
||||||
rae.name,
|
rae.name,
|
||||||
rae."type",
|
rae."type",
|
||||||
rae.created_at as created_at,
|
rae.created_at as created_at,
|
||||||
@@ -83,6 +89,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 AND rae.deleted_at IS NULL
|
||||||
|
)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $limit;
|
LIMIT $limit;
|
||||||
|
|
||||||
@@ -136,6 +149,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 AND rae.deleted_at IS NULL
|
||||||
|
)
|
||||||
)
|
)
|
||||||
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, COALESCE((SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, 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) AND rae.deleted_at IS NULL) 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) AND rae.deleted_at IS NULL)) 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, so 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 => 27;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -276,6 +276,11 @@ 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);
|
||||||
|
await m.addColumn(v27.localAssetEntity, v27.localAssetEntity.syncedChecksum);
|
||||||
|
await m.createIndex(v27.idxLocalAssetPriorRemoteId);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
i7.idxLocalAlbumAssetAlbumAsset,
|
i7.idxLocalAlbumAssetAlbumAsset,
|
||||||
i4.idxLocalAssetChecksum,
|
i4.idxLocalAssetChecksum,
|
||||||
i4.idxLocalAssetCloudId,
|
i4.idxLocalAssetCloudId,
|
||||||
|
i4.idxLocalAssetPriorRemoteId,
|
||||||
i3.idxStackPrimaryAssetId,
|
i3.idxStackPrimaryAssetId,
|
||||||
i2.uQRemoteAssetsOwnerChecksum,
|
i2.uQRemoteAssetsOwnerChecksum,
|
||||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
|||||||
@@ -13539,6 +13539,613 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
|
|||||||
type: i1.DriftSqlType.string,
|
type: i1.DriftSqlType.string,
|
||||||
$customConstraints: 'NULL',
|
$customConstraints: 'NULL',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final class Schema27 extends i0.VersionedSchema {
|
||||||
|
Schema27({required super.database}) : super(version: 27);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
userEntity,
|
||||||
|
remoteAssetEntity,
|
||||||
|
stackEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
remoteAlbumEntity,
|
||||||
|
localAlbumEntity,
|
||||||
|
localAlbumAssetEntity,
|
||||||
|
idxLocalAlbumAssetAlbumAsset,
|
||||||
|
idxLocalAssetChecksum,
|
||||||
|
idxLocalAssetCloudId,
|
||||||
|
idxLocalAssetPriorRemoteId,
|
||||||
|
idxStackPrimaryAssetId,
|
||||||
|
uQRemoteAssetsOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
idxRemoteAssetChecksum,
|
||||||
|
idxRemoteAssetStackId,
|
||||||
|
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||||
|
authUserEntity,
|
||||||
|
userMetadataEntity,
|
||||||
|
partnerEntity,
|
||||||
|
remoteExifEntity,
|
||||||
|
remoteAlbumAssetEntity,
|
||||||
|
remoteAlbumUserEntity,
|
||||||
|
remoteAssetCloudIdEntity,
|
||||||
|
memoryEntity,
|
||||||
|
memoryAssetEntity,
|
||||||
|
personEntity,
|
||||||
|
assetFaceEntity,
|
||||||
|
storeEntity,
|
||||||
|
trashedLocalAssetEntity,
|
||||||
|
assetEditEntity,
|
||||||
|
metadata,
|
||||||
|
idxPartnerSharedWithId,
|
||||||
|
idxLatLng,
|
||||||
|
idxRemoteExifCity,
|
||||||
|
idxRemoteAlbumAssetAlbumAsset,
|
||||||
|
idxRemoteAssetCloudId,
|
||||||
|
idxPersonOwnerId,
|
||||||
|
idxAssetFacePersonId,
|
||||||
|
idxAssetFaceAssetId,
|
||||||
|
idxAssetFaceVisiblePerson,
|
||||||
|
idxTrashedLocalAssetChecksum,
|
||||||
|
idxTrashedLocalAssetAlbum,
|
||||||
|
idxAssetEditAssetId,
|
||||||
|
];
|
||||||
|
late final Shape33 userEntity = Shape33(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_108,
|
||||||
|
_column_109,
|
||||||
|
_column_110,
|
||||||
|
_column_111,
|
||||||
|
_column_112,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape50 remoteAssetEntity = Shape50(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_108,
|
||||||
|
_column_113,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_116,
|
||||||
|
_column_117,
|
||||||
|
_column_118,
|
||||||
|
_column_107,
|
||||||
|
_column_119,
|
||||||
|
_column_120,
|
||||||
|
_column_121,
|
||||||
|
_column_122,
|
||||||
|
_column_123,
|
||||||
|
_column_124,
|
||||||
|
_column_212,
|
||||||
|
_column_125,
|
||||||
|
_column_126,
|
||||||
|
_column_127,
|
||||||
|
_column_128,
|
||||||
|
_column_129,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape35 stackEntity = Shape35(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'stack_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_121,
|
||||||
|
_column_130,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape51 localAssetEntity = Shape51(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_108,
|
||||||
|
_column_113,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_116,
|
||||||
|
_column_117,
|
||||||
|
_column_118,
|
||||||
|
_column_107,
|
||||||
|
_column_131,
|
||||||
|
_column_120,
|
||||||
|
_column_132,
|
||||||
|
_column_133,
|
||||||
|
_column_134,
|
||||||
|
_column_135,
|
||||||
|
_column_136,
|
||||||
|
_column_137,
|
||||||
|
_column_213,
|
||||||
|
_column_214,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape48 remoteAlbumEntity = Shape48(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_108,
|
||||||
|
_column_138,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_139,
|
||||||
|
_column_140,
|
||||||
|
_column_141,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape38 localAlbumEntity = Shape38(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_108,
|
||||||
|
_column_115,
|
||||||
|
_column_142,
|
||||||
|
_column_143,
|
||||||
|
_column_144,
|
||||||
|
_column_145,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_146, _column_147, _column_145],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||||
|
'idx_local_album_asset_album_asset',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||||
|
'idx_local_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||||
|
'idx_local_asset_cloud_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetPriorRemoteId = i1.Index(
|
||||||
|
'idx_local_asset_prior_remote_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||||
|
'idx_stack_primary_asset_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_library_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||||
|
'idx_remote_asset_stack_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||||
|
'idx_remote_asset_owner_visibility_deleted_created',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||||
|
);
|
||||||
|
late final Shape40 authUserEntity = Shape40(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'auth_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_108,
|
||||||
|
_column_109,
|
||||||
|
_column_148,
|
||||||
|
_column_110,
|
||||||
|
_column_111,
|
||||||
|
_column_149,
|
||||||
|
_column_150,
|
||||||
|
_column_151,
|
||||||
|
_column_152,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape4 userMetadataEntity = Shape4(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_metadata_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||||
|
columns: [_column_153, _column_154, _column_155],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape41 partnerEntity = Shape41(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'partner_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||||
|
columns: [_column_156, _column_157, _column_158],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape42 remoteExifEntity = Shape42(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_exif_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_159,
|
||||||
|
_column_160,
|
||||||
|
_column_161,
|
||||||
|
_column_162,
|
||||||
|
_column_163,
|
||||||
|
_column_164,
|
||||||
|
_column_117,
|
||||||
|
_column_116,
|
||||||
|
_column_165,
|
||||||
|
_column_166,
|
||||||
|
_column_167,
|
||||||
|
_column_168,
|
||||||
|
_column_135,
|
||||||
|
_column_136,
|
||||||
|
_column_169,
|
||||||
|
_column_170,
|
||||||
|
_column_171,
|
||||||
|
_column_172,
|
||||||
|
_column_173,
|
||||||
|
_column_174,
|
||||||
|
_column_175,
|
||||||
|
_column_176,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_159, _column_177],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||||
|
columns: [_column_177, _column_153, _column_178],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_cloud_id_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_159,
|
||||||
|
_column_179,
|
||||||
|
_column_180,
|
||||||
|
_column_134,
|
||||||
|
_column_135,
|
||||||
|
_column_136,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape44 memoryEntity = Shape44(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_124,
|
||||||
|
_column_121,
|
||||||
|
_column_113,
|
||||||
|
_column_181,
|
||||||
|
_column_182,
|
||||||
|
_column_183,
|
||||||
|
_column_184,
|
||||||
|
_column_185,
|
||||||
|
_column_186,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape12 memoryAssetEntity = Shape12(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||||
|
columns: [_column_159, _column_187],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape45 personEntity = Shape45(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'person_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_121,
|
||||||
|
_column_108,
|
||||||
|
_column_188,
|
||||||
|
_column_189,
|
||||||
|
_column_190,
|
||||||
|
_column_191,
|
||||||
|
_column_192,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape46 assetFaceEntity = Shape46(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_face_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_159,
|
||||||
|
_column_193,
|
||||||
|
_column_194,
|
||||||
|
_column_195,
|
||||||
|
_column_196,
|
||||||
|
_column_197,
|
||||||
|
_column_198,
|
||||||
|
_column_199,
|
||||||
|
_column_200,
|
||||||
|
_column_201,
|
||||||
|
_column_124,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape18 storeEntity = Shape18(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'store_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_202, _column_203, _column_204],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'trashed_local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_108,
|
||||||
|
_column_113,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_116,
|
||||||
|
_column_117,
|
||||||
|
_column_118,
|
||||||
|
_column_107,
|
||||||
|
_column_205,
|
||||||
|
_column_131,
|
||||||
|
_column_120,
|
||||||
|
_column_132,
|
||||||
|
_column_206,
|
||||||
|
_column_137,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape32 assetEditEntity = Shape32(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_edit_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_107,
|
||||||
|
_column_159,
|
||||||
|
_column_207,
|
||||||
|
_column_208,
|
||||||
|
_column_209,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape49 metadata = Shape49(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'metadata',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY("key")'],
|
||||||
|
columns: [_column_210, _column_211, _column_115],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||||
|
'idx_partner_shared_with_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxLatLng = i1.Index(
|
||||||
|
'idx_lat_lng',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteExifCity = i1.Index(
|
||||||
|
'idx_remote_exif_city',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||||
|
'idx_remote_album_asset_album_asset',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||||
|
'idx_remote_asset_cloud_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxPersonOwnerId = i1.Index(
|
||||||
|
'idx_person_owner_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||||
|
'idx_asset_face_person_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||||
|
'idx_asset_face_asset_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||||
|
'idx_asset_face_visible_person',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||||
|
);
|
||||||
|
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||||
|
'idx_trashed_local_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||||
|
'idx_trashed_local_asset_album',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||||
|
);
|
||||||
|
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||||
|
'idx_asset_edit_asset_id',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape51 extends i0.VersionedTable {
|
||||||
|
Shape51({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get name =>
|
||||||
|
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get type =>
|
||||||
|
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get updatedAt =>
|
||||||
|
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get width =>
|
||||||
|
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get height =>
|
||||||
|
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get durationMs =>
|
||||||
|
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get checksum =>
|
||||||
|
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get isFavorite =>
|
||||||
|
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get orientation =>
|
||||||
|
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get iCloudId =>
|
||||||
|
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get adjustmentTime =>
|
||||||
|
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<double> get latitude =>
|
||||||
|
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||||
|
i1.GeneratedColumn<double> get longitude =>
|
||||||
|
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||||
|
i1.GeneratedColumn<int> get playbackStyle =>
|
||||||
|
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get priorRemoteId =>
|
||||||
|
columnsByName['prior_remote_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get syncedChecksum =>
|
||||||
|
columnsByName['synced_checksum']! as i1.GeneratedColumn<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_213(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'prior_remote_id',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
$customConstraints: 'NULL',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<String> _column_214(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'synced_checksum',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
$customConstraints: 'NULL',
|
||||||
|
);
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
@@ -13565,6 +14172,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -13693,6 +14301,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from25To26(migrator, schema);
|
await from25To26(migrator, schema);
|
||||||
return 26;
|
return 26;
|
||||||
|
case 26:
|
||||||
|
final schema = Schema27(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from26To27(migrator, schema);
|
||||||
|
return 27;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
@@ -13725,6 +14338,7 @@ i1.OnUpgrade stepByStep({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
@@ -13752,5 +14366,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
from23To24: from23To24,
|
from23To24: from23To24,
|
||||||
from24To25: from24To25,
|
from24To25: from24To25,
|
||||||
from25To26: from25To26,
|
from25To26: from25To26,
|
||||||
|
from26To27: from26To27,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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. That's the revert case: 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 AND prior.deleted_at IS NULL
|
||||||
|
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 {
|
||||||
|
|||||||
+129
-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);
|
||||||
}
|
}
|
||||||
@@ -654,6 +716,25 @@ class NativeSyncApi {
|
|||||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||||
@@ -672,4 +753,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
// ignore_for_file: unused_import, unused_shown_name
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
|
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||||
|
if (replyList == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'Unable to establish connection on channel: "$channelName".',
|
||||||
|
);
|
||||||
|
} else if (replyList.length > 1) {
|
||||||
|
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||||
|
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return replyList.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
|
const _PigeonCodec();
|
||||||
|
@override
|
||||||
|
void writeValue(WriteBuffer buffer, Object? value) {
|
||||||
|
if (value is int) {
|
||||||
|
buffer.putUint8(4);
|
||||||
|
buffer.putInt64(value);
|
||||||
|
} else {
|
||||||
|
super.writeValue(buffer, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||||
|
switch (type) {
|
||||||
|
default:
|
||||||
|
return super.readValueOfType(type, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PermissionApi {
|
||||||
|
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||||
|
/// available for dependency injection. If it is left null, the default
|
||||||
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
|
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
Future<bool> hasManageMediaPermission() async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> requestManageMediaPermission() async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> manageMediaPermission() async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
|||||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
import 'package:immich_mobile/platform/background_worker_lock_api.g.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/platform/local_image_api.g.dart';
|
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||||
|
|
||||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||||
@@ -16,6 +17,8 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
|
|||||||
|
|
||||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||||
|
|
||||||
|
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||||
|
|
||||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||||
|
|
||||||
final localImageApi = LocalImageApi();
|
final localImageApi = LocalImageApi();
|
||||||
|
|||||||
@@ -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,8 +12,10 @@ 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/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
|
|
||||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||||
|
|
||||||
@@ -22,8 +25,8 @@ final syncStreamServiceProvider = Provider(
|
|||||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||||
storageRepository: ref.watch(storageRepositoryProvider),
|
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||||
api: ref.watch(apiServiceProvider),
|
api: ref.watch(apiServiceProvider),
|
||||||
cancelChecker: ref.watch(cancellationProvider),
|
cancelChecker: ref.watch(cancellationProvider),
|
||||||
@@ -39,17 +42,28 @@ final localSyncServiceProvider = Provider(
|
|||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||||
storageRepository: ref.watch(storageRepositoryProvider),
|
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
final assetMediaRepositoryProvider = Provider(
|
||||||
|
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
class AssetMediaRepository {
|
class AssetMediaRepository {
|
||||||
final AssetApiRepository _assetApiRepository;
|
final AssetApiRepository _assetApiRepository;
|
||||||
|
final NativeSyncApi _nativeSyncApi;
|
||||||
static final Logger _log = Logger("AssetMediaRepository");
|
static final Logger _log = Logger("AssetMediaRepository");
|
||||||
|
|
||||||
const AssetMediaRepository(this._assetApiRepository);
|
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||||
|
|
||||||
Future<bool> _androidSupportsTrash() async {
|
Future<bool> _androidSupportsTrash() async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
@@ -45,6 +50,27 @@ class AssetMediaRepository {
|
|||||||
return PhotoManager.editor.deleteWithIds(ids);
|
return PhotoManager.editor.deleteWithIds(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
|
||||||
|
try {
|
||||||
|
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
|
||||||
|
} catch (e, s) {
|
||||||
|
_log.warning('Error restore file from trash by Id', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||||
|
final restoredIds = <String>[];
|
||||||
|
for (final asset in assets) {
|
||||||
|
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
|
||||||
|
final result = await _restoreFromTrashById(asset.id, asset.type.index);
|
||||||
|
if (result) {
|
||||||
|
restoredIds.add(asset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return restoredIds;
|
||||||
|
}
|
||||||
|
|
||||||
Future<AssetEntity?> get(String id) async {
|
Future<AssetEntity?> get(String id) async {
|
||||||
final entity = await AssetEntity.fromId(id);
|
final entity = await AssetEntity.fromId(id);
|
||||||
return entity;
|
return entity;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/services/local_files_manager.service.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
final localFilesManagerRepositoryProvider = Provider(
|
|
||||||
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
|
||||||
);
|
|
||||||
|
|
||||||
class LocalFilesManagerRepository {
|
|
||||||
LocalFilesManagerRepository(this._service);
|
|
||||||
|
|
||||||
final Logger _logger = Logger('LocalFilesManagerRepo');
|
|
||||||
final LocalFilesManagerService _service;
|
|
||||||
|
|
||||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
|
||||||
return await _service.moveToTrash(mediaUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
|
||||||
return await _service.restoreFromTrash(fileName, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> requestManageMediaPermission() async {
|
|
||||||
return await _service.requestManageMediaPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> hasManageMediaPermission() async {
|
|
||||||
return await _service.hasManageMediaPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> manageMediaPermission() async {
|
|
||||||
return await _service.manageMediaPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
|
||||||
final restoredIds = <String>[];
|
|
||||||
for (final asset in assets) {
|
|
||||||
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
|
|
||||||
try {
|
|
||||||
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
|
|
||||||
if (result) {
|
|
||||||
restoredIds.add(asset.id);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.warning("Restoring failure: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return restoredIds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
final permissionRepositoryProvider = Provider((_) {
|
final permissionRepositoryProvider = Provider((ref) {
|
||||||
return const PermissionRepository();
|
return PermissionRepository(ref.watch(permissionApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
class PermissionRepository implements IPermissionRepository {
|
class PermissionRepository implements IPermissionRepository {
|
||||||
const PermissionRepository();
|
final PermissionApi _permissionApi;
|
||||||
|
|
||||||
|
const PermissionRepository(this._permissionApi);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> hasLocationWhenInUsePermission() {
|
Future<bool> hasLocationWhenInUsePermission() {
|
||||||
@@ -34,6 +38,21 @@ class PermissionRepository implements IPermissionRepository {
|
|||||||
Future<bool> openSettings() {
|
Future<bool> openSettings() {
|
||||||
return openAppSettings();
|
return openAppSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasManageMediaPermission() {
|
||||||
|
return _permissionApi.hasManageMediaPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestManageMediaPermission() {
|
||||||
|
return _permissionApi.requestManageMediaPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> manageMediaPermission() {
|
||||||
|
return _permissionApi.manageMediaPermission();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract interface class IPermissionRepository {
|
abstract interface class IPermissionRepository {
|
||||||
@@ -42,4 +61,7 @@ abstract interface class IPermissionRepository {
|
|||||||
Future<bool> hasLocationAlwaysPermission();
|
Future<bool> hasLocationAlwaysPermission();
|
||||||
Future<bool> requestLocationAlwaysPermission();
|
Future<bool> requestLocationAlwaysPermission();
|
||||||
Future<bool> openSettings();
|
Future<bool> openSettings();
|
||||||
|
Future<bool> hasManageMediaPermission();
|
||||||
|
Future<bool> requestManageMediaPermission();
|
||||||
|
Future<bool> manageMediaPermission();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -193,10 +237,13 @@ class BackgroundUploadService {
|
|||||||
|
|
||||||
await _storageRepository.clearCache();
|
await _storageRepository.clearCache();
|
||||||
await _uploadRepository.reset(kBackupGroup);
|
await _uploadRepository.reset(kBackupGroup);
|
||||||
|
await _uploadRepository.reset(kBackupEditPairGroup);
|
||||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||||
|
await _uploadRepository.deleteDatabaseRecords(kBackupEditPairGroup);
|
||||||
|
|
||||||
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
|
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
|
||||||
return activeTasks.length;
|
final activeEditTasks = await _uploadRepository.getActiveTasks(kBackupEditPairGroup);
|
||||||
|
return activeTasks.length + activeEditTasks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume background backup processing
|
/// Resume background backup processing
|
||||||
@@ -205,9 +252,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 +278,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 +317,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.isLivePhotos || 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 +462,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,81 @@
|
|||||||
|
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.
|
||||||
|
///
|
||||||
|
/// A photo that was never edited only carries the capture-time Photographic Style,
|
||||||
|
/// which iOS stamps at [LocalAsset.createdAt]; a real edit moves [LocalAsset.adjustmentTime]
|
||||||
|
/// later. When they match (or there's no adjustment at all) there's nothing to stack, so
|
||||||
|
/// we skip the native read. Anything that moved the timestamp (edit, retime, revert) falls
|
||||||
|
/// through to [NativeSyncApi.getBaseResource], which reads the adjustment plist and decides.
|
||||||
|
Future<EditPairPlan> resolveEditPair(NativeSyncApi nativeSyncApi, LocalAsset asset, {Logger? log}) async {
|
||||||
|
if (asset.priorRemoteId != null) {
|
||||||
|
return AbsorbIntoPrior(asset.priorRemoteId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_mightBeEdited(asset)) {
|
||||||
|
return const NoEditPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS stamps the capture-time Photographic Style at the creation time and moves the
|
||||||
|
/// adjustment timestamp on any later change. A gap past a small tolerance (capture jitter
|
||||||
|
/// is sub-second, real edits are seconds apart) is worth a native check; no adjustment at
|
||||||
|
/// all means the photo was never touched.
|
||||||
|
bool _mightBeEdited(LocalAsset asset) {
|
||||||
|
final adjustedAt = asset.adjustmentTime;
|
||||||
|
if (adjustedAt == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return adjustedAt.difference(asset.createdAt).inSeconds.abs() > _editTimestampToleranceSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _editTimestampToleranceSeconds = 2;
|
||||||
@@ -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,13 @@ class ForegroundUploadService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final stackParentId = entity.isLivePhoto
|
||||||
|
? null
|
||||||
|
: 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 +412,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 +450,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,
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
|
|
||||||
|
|
||||||
class LocalFilesManagerService {
|
|
||||||
const LocalFilesManagerService();
|
|
||||||
|
|
||||||
static final Logger _logger = Logger('LocalFilesManager');
|
|
||||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
|
||||||
|
|
||||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error moving file to trash', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error restore file from trash', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error restore file from trash by Id', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> requestManageMediaPermission() async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error requesting manage media permission', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> hasManageMediaPermission() async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('hasManageMediaPermission');
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error requesting manage media permission state', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> manageMediaPermission() async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('manageMediaPermission');
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error requesting manage media permission settings', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getManageMediaPermission() async {
|
getManageMediaPermission() async {
|
||||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/metadata.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/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||||
@@ -57,9 +57,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
() async {
|
() async {
|
||||||
isManageMediaSupported.value = await checkAndroidVersion();
|
isManageMediaSupported.value = await checkAndroidVersion();
|
||||||
if (isManageMediaSupported.value) {
|
if (isManageMediaSupported.value) {
|
||||||
manageMediaAndroidPermission.value = await ref
|
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||||
.read(localFilesManagerRepositoryProvider)
|
|
||||||
.hasManageMediaPermission();
|
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
return null;
|
return null;
|
||||||
@@ -82,7 +80,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value) {
|
if (value) {
|
||||||
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
|
||||||
manageLocalMediaAndroid.value = result;
|
manageLocalMediaAndroid.value = result;
|
||||||
manageMediaAndroidPermission.value = result;
|
manageMediaAndroidPermission.value = result;
|
||||||
}
|
}
|
||||||
@@ -96,7 +94,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
? const Color.fromARGB(255, 243, 188, 106)
|
? const Color.fromARGB(255, 243, 188, 106)
|
||||||
: null,
|
: null,
|
||||||
onActionTap: () async {
|
onActionTap: () async {
|
||||||
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||||
manageMediaAndroidPermission.value = result;
|
manageMediaAndroidPermission.value = result;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
dartPackageName: 'immich_mobile',
|
dartPackageName: 'immich_mobile',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
enum PlatformAssetPlaybackStyle {
|
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||||
unknown,
|
|
||||||
image,
|
|
||||||
video,
|
|
||||||
imageAnimated,
|
|
||||||
livePhoto,
|
|
||||||
videoLooping,
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -110,6 +103,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();
|
||||||
@@ -142,6 +150,17 @@ abstract class NativeSyncApi {
|
|||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||||
|
|
||||||
|
@async
|
||||||
|
bool restoreFromTrashById(String mediaId, int type);
|
||||||
|
|
||||||
@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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:pigeon/pigeon.dart';
|
||||||
|
|
||||||
|
@ConfigurePigeon(
|
||||||
|
PigeonOptions(
|
||||||
|
dartOut: 'lib/platform/permission_api.g.dart',
|
||||||
|
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
|
||||||
|
swiftOptions: SwiftOptions(),
|
||||||
|
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||||
|
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||||
|
dartOptions: DartOptions(),
|
||||||
|
dartPackageName: 'immich_mobile',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@HostApi()
|
||||||
|
abstract class PermissionApi {
|
||||||
|
bool hasManageMediaPermission();
|
||||||
|
|
||||||
|
@async
|
||||||
|
bool requestManageMediaPermission();
|
||||||
|
|
||||||
|
@async
|
||||||
|
bool manageMediaPermission();
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -10,17 +10,15 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
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/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../../domain/service.mock.dart';
|
import '../../domain/service.mock.dart';
|
||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../mocks/asset_entity.mock.dart';
|
|
||||||
import '../../repository.mocks.dart';
|
import '../../repository.mocks.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -28,8 +26,8 @@ void main() {
|
|||||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||||
late LocalFilesManagerRepository mockLocalFilesManager;
|
late AssetMediaRepository mockAssetMediaRepository;
|
||||||
late StorageRepository mockStorageRepository;
|
late MockPermissionRepository mockPermissionRepository;
|
||||||
late MockNativeSyncApi mockNativeSyncApi;
|
late MockNativeSyncApi mockNativeSyncApi;
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
||||||
@@ -51,8 +49,8 @@ void main() {
|
|||||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||||
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||||
mockStorageRepository = MockStorageRepository();
|
mockPermissionRepository = MockPermissionRepository();
|
||||||
mockNativeSyncApi = MockNativeSyncApi();
|
mockNativeSyncApi = MockNativeSyncApi();
|
||||||
|
|
||||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||||
@@ -65,25 +63,28 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||||
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
|
||||||
|
final ids = invocation.positionalArguments.first as List<String>;
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
sut = LocalSyncService(
|
sut = LocalSyncService(
|
||||||
localAlbumRepository: mockLocalAlbumRepository,
|
localAlbumRepository: mockLocalAlbumRepository,
|
||||||
localAssetRepository: mockLocalAssetRepository,
|
localAssetRepository: mockLocalAssetRepository,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||||
localFilesManager: mockLocalFilesManager,
|
assetMediaRepository: mockAssetMediaRepository,
|
||||||
storageRepository: mockStorageRepository,
|
permissionRepository: mockPermissionRepository,
|
||||||
nativeSyncApi: mockNativeSyncApi,
|
nativeSyncApi: mockNativeSyncApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ void main() {
|
|||||||
|
|
||||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ void main() {
|
|||||||
|
|
||||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ void main() {
|
|||||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||||
|
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -131,13 +132,13 @@ void main() {
|
|||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
orientation: 0,
|
orientation: 0,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetsToRestore = [LocalAssetStub.image1];
|
final assetsToRestore = [LocalAssetStub.image1];
|
||||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||||
final restoredIds = ['image1'];
|
final restoredIds = ['image1'];
|
||||||
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||||
expect(requested, orderedEquals(assetsToRestore));
|
expect(requested, orderedEquals(assetsToRestore));
|
||||||
return restoredIds;
|
return restoredIds;
|
||||||
@@ -150,10 +151,6 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetEntity = MockAssetEntity();
|
|
||||||
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
|
||||||
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
|
||||||
|
|
||||||
await sut.processTrashedAssets({
|
await sut.processTrashedAssets({
|
||||||
'album-a': [platformAsset],
|
'album-a': [platformAsset],
|
||||||
});
|
});
|
||||||
@@ -168,12 +165,11 @@ void main() {
|
|||||||
expect(trashedEntry.asset.name, platformAsset.name);
|
expect(trashedEntry.asset.name, platformAsset.name);
|
||||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||||
|
|
||||||
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
|
||||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||||
|
|
||||||
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
|
||||||
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
expect(moveArgs, ['local-trash']);
|
||||||
expect(moveArgs, ['content://local-trash']);
|
|
||||||
final trashArgs =
|
final trashArgs =
|
||||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||||
as Map<String, List<LocalAsset>>;
|
as Map<String, List<LocalAsset>>;
|
||||||
@@ -181,6 +177,26 @@ void main() {
|
|||||||
expect(trashArgs['album-a'], [localAssetToTrash]);
|
expect(trashArgs['album-a'], [localAssetToTrash]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('records only local assets that were moved to device trash', () async {
|
||||||
|
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||||
|
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||||
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
|
||||||
|
(_) async => {
|
||||||
|
'album-a': [movedAsset],
|
||||||
|
'album-b': [skippedAsset],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||||
|
|
||||||
|
await sut.processTrashedAssets({});
|
||||||
|
|
||||||
|
final trashArgs =
|
||||||
|
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||||
|
as Map<String, List<LocalAsset>>;
|
||||||
|
expect(trashArgs.keys, ['album-a']);
|
||||||
|
expect(trashArgs['album-a'], [movedAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
test('does not attempt restore when repository has no assets to restore', () async {
|
test('does not attempt restore when repository has no assets to restore', () async {
|
||||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||||
|
|
||||||
@@ -190,7 +206,7 @@ void main() {
|
|||||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||||
as Iterable<TrashedAsset>;
|
as Iterable<TrashedAsset>;
|
||||||
expect(trashedSnapshot, isEmpty);
|
expect(trashedSnapshot, isEmpty);
|
||||||
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,7 +215,7 @@ void main() {
|
|||||||
|
|
||||||
await sut.processTrashedAssets({});
|
await sut.processTrashedAssets({});
|
||||||
|
|
||||||
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -215,7 +231,7 @@ void main() {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
createdAt: 1700000000,
|
createdAt: 1700000000,
|
||||||
updatedAt: 1732000000,
|
updatedAt: 1732000000,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||||
);
|
);
|
||||||
|
|
||||||
final localAsset = platformAsset.toLocalAsset();
|
final localAsset = platformAsset.toLocalAsset();
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/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';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.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/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@@ -26,7 +25,6 @@ import '../../api.mocks.dart';
|
|||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../fixtures/sync_stream.stub.dart';
|
import '../../fixtures/sync_stream.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../mocks/asset_entity.mock.dart';
|
|
||||||
import '../../repository.mocks.dart';
|
import '../../repository.mocks.dart';
|
||||||
import '../../service.mocks.dart';
|
import '../../service.mocks.dart';
|
||||||
|
|
||||||
@@ -52,8 +50,8 @@ void main() {
|
|||||||
late SyncApiRepository mockSyncApiRepo;
|
late SyncApiRepository mockSyncApiRepo;
|
||||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||||
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
late AssetMediaRepository mockAssetMediaRepo;
|
||||||
late StorageRepository mockStorageRepo;
|
late MockPermissionRepository mockPermissionRepo;
|
||||||
late MockApiService mockApi;
|
late MockApiService mockApi;
|
||||||
late MockServerApi mockServerApi;
|
late MockServerApi mockServerApi;
|
||||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||||
@@ -86,8 +84,8 @@ void main() {
|
|||||||
mockSyncApiRepo = MockSyncApiRepository();
|
mockSyncApiRepo = MockSyncApiRepository();
|
||||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||||
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
mockAssetMediaRepo = MockAssetMediaRepository();
|
||||||
mockStorageRepo = MockStorageRepository();
|
mockPermissionRepo = MockPermissionRepository();
|
||||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
mockApi = MockApiService();
|
mockApi = MockApiService();
|
||||||
@@ -159,8 +157,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
localFilesManager: mockLocalFilesManagerRepo,
|
assetMediaRepository: mockAssetMediaRepo,
|
||||||
storageRepository: mockStorageRepo,
|
permissionRepository: mockPermissionRepo,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
);
|
);
|
||||||
@@ -170,10 +168,12 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||||
hasManageMediaPermission = false;
|
hasManageMediaPermission = false;
|
||||||
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
final ids = invocation.positionalArguments.first as List<String>;
|
||||||
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
return ids;
|
||||||
|
});
|
||||||
|
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,8 +241,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
localFilesManager: mockLocalFilesManagerRepo,
|
assetMediaRepository: mockAssetMediaRepo,
|
||||||
storageRepository: mockStorageRepo,
|
permissionRepository: mockPermissionRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
@@ -282,8 +282,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
localFilesManager: mockLocalFilesManagerRepo,
|
assetMediaRepository: mockAssetMediaRepo,
|
||||||
storageRepository: mockStorageRepo,
|
permissionRepository: mockPermissionRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
@@ -424,18 +424,10 @@ void main() {
|
|||||||
return assetsByAlbum;
|
return assetsByAlbum;
|
||||||
});
|
});
|
||||||
|
|
||||||
final localEntity = MockAssetEntity();
|
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||||
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
final ids = invocation.positionalArguments.first as List<String>;
|
||||||
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
expect(ids, unorderedEquals(['local-only', 'merged-local']));
|
||||||
|
return ids;
|
||||||
final mergedEntity = MockAssetEntity();
|
|
||||||
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
|
|
||||||
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
|
|
||||||
|
|
||||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
|
|
||||||
final urls = invocation.positionalArguments.first as List<String>;
|
|
||||||
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final events = [
|
final events = [
|
||||||
@@ -461,10 +453,51 @@ void main() {
|
|||||||
|
|
||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
final trashArgs =
|
||||||
|
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||||
|
as Map<String, List<LocalAsset>>;
|
||||||
|
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
|
||||||
|
expect(trashArgs['album-a'], [localAsset]);
|
||||||
|
expect(trashArgs['album-b'], [mergedAsset]);
|
||||||
|
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
|
||||||
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("records only assets that were moved to device trash", () async {
|
||||||
|
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||||
|
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||||
|
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
|
||||||
|
(_) async => {
|
||||||
|
'album-a': [movedAsset],
|
||||||
|
'album-b': [skippedAsset],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||||
|
|
||||||
|
final events = [
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-moved',
|
||||||
|
checksum: movedAsset.checksum!,
|
||||||
|
ack: 'asset-remote-moved',
|
||||||
|
trashedAt: DateTime(2025, 5, 1),
|
||||||
|
),
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-skipped',
|
||||||
|
checksum: skippedAsset.checksum!,
|
||||||
|
ack: 'asset-remote-skipped',
|
||||||
|
trashedAt: DateTime(2025, 5, 2),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await simulateEvents(events);
|
||||||
|
|
||||||
|
final trashArgs =
|
||||||
|
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||||
|
as Map<String, List<LocalAsset>>;
|
||||||
|
expect(trashArgs.keys, ['album-a']);
|
||||||
|
expect(trashArgs['album-a'], [movedAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
test("skips device trashing when no local assets match the remote trash payload", () async {
|
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||||
final events = [
|
final events = [
|
||||||
SyncStreamStub.assetTrashed(
|
SyncStreamStub.assetTrashed(
|
||||||
@@ -478,7 +511,7 @@ void main() {
|
|||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,7 +527,7 @@ void main() {
|
|||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +538,7 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||||
|
|
||||||
final restoredIds = ['trashed-1'];
|
final restoredIds = ['trashed-1'];
|
||||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||||
return restoredIds;
|
return restoredIds;
|
||||||
|
|||||||
+4
@@ -30,6 +30,7 @@ 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;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -87,6 +88,8 @@ 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);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
@@ -119,5 +122,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
24,
|
24,
|
||||||
25,
|
25,
|
||||||
26,
|
26,
|
||||||
|
27,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+9471
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 {}
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|||||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||||
|
|
||||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||||
|
|
||||||
|
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||||
|
|
||||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||||
|
|
||||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||||
|
|
||||||
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
|
|
||||||
|
|
||||||
class MockTagService extends Mock implements TagService {}
|
class MockTagService extends Mock implements TagService {}
|
||||||
|
|||||||
@@ -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,234 @@ 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',
|
||||||
|
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
|
||||||
|
);
|
||||||
|
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',
|
||||||
|
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gate: skips the native read for an unedited photo (adjustmentTime == createdAt)', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
|
final asset = LocalAssetStub.image1.copyWith(adjustmentTime: LocalAssetStub.image1.createdAt);
|
||||||
|
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');
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.group, kBackupGroup);
|
||||||
|
expect(task.fields.containsKey('stackParentId'), isFalse);
|
||||||
|
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gate: skips the native read when the photo has no adjustmentTime', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
|
||||||
|
final asset = LocalAssetStub.image1; // adjustmentTime is null
|
||||||
|
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');
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.group, kBackupGroup);
|
||||||
|
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPriorRemoteIdOnSuccess: skips live photos', () async {
|
||||||
|
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: true, livePhotoVideoId: '');
|
||||||
|
final update = TaskStatusUpdate(
|
||||||
|
UploadTask(url: 'http://test-server.com', filename: 'live.mov'),
|
||||||
|
TaskStatus.complete,
|
||||||
|
null,
|
||||||
|
'{"id":"video-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 +420,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
@@ -222,6 +472,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutAndroid.dispose());
|
addTearDown(() => sutAndroid.dispose());
|
||||||
|
|
||||||
@@ -262,6 +514,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
|
mockNativeSyncApi,
|
||||||
|
mockEditRevertService,
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
@@ -302,6 +556,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 = {
|
||||||
|
|||||||
+2
-2
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
|
|||||||
COPY --from=server /output/server-pruned ./server
|
COPY --from=server /output/server-pruned ./server
|
||||||
COPY --from=web /usr/src/app/web/build /build/www
|
COPY --from=web /usr/src/app/web/build /build/www
|
||||||
COPY --from=cli /output/cli-pruned ./cli
|
COPY --from=cli /output/cli-pruned ./cli
|
||||||
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
|
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist
|
||||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
|
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json
|
||||||
RUN ln -s ../../cli/bin/immich server/bin/immich
|
RUN ln -s ../../cli/bin/immich server/bin/immich
|
||||||
COPY LICENSE /licenses/LICENSE.txt
|
COPY LICENSE /licenses/LICENSE.txt
|
||||||
COPY LICENSE /LICENSE
|
COPY LICENSE /LICENSE
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
|
|
||||||
}
|
|
||||||
@@ -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