mirror of
https://github.com/immich-app/immich.git
synced 2026-07-17 13:44:36 +03:00
Compare commits
1 Commits
main
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff7d92b380 |
@@ -18,6 +18,7 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.media.AssetMediaApiImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
@@ -66,6 +67,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
flutterEngine.plugins.add(permissionApiImpl)
|
||||
flutterEngine.plugins.add(AssetMediaApiImpl(ctx))
|
||||
}
|
||||
|
||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||
@@ -75,6 +77,8 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
nativeApi?.detachFromEngine()
|
||||
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||
permissionApi?.detachFromEngine()
|
||||
val assetMediaApi = flutterEngine.plugins.get(AssetMediaApiImpl::class.java) as ImmichPlugin?
|
||||
assetMediaApi?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,30 @@ package app.alextran.immich.core
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
abstract class ImmichPlugin : FlutterPlugin {
|
||||
private var detached: Boolean = false;
|
||||
private var detached: Boolean = false
|
||||
|
||||
private var supervisor = SupervisorJob()
|
||||
protected var scope = CoroutineScope(supervisor + Dispatchers.Default)
|
||||
private set
|
||||
|
||||
@CallSuper
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
detached = false;
|
||||
detached = false
|
||||
if (!scope.isActive) {
|
||||
supervisor = SupervisorJob()
|
||||
scope = CoroutineScope(supervisor + Dispatchers.Default)
|
||||
}
|
||||
}
|
||||
|
||||
fun detachFromEngine() {
|
||||
detached = true
|
||||
supervisor.cancel()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
@@ -22,8 +35,9 @@ abstract class ImmichPlugin : FlutterPlugin {
|
||||
|
||||
fun <T> completeWhenActive(callback: (T) -> Unit, value: T) {
|
||||
if (detached) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
callback(value);
|
||||
|
||||
callback(value)
|
||||
}
|
||||
}
|
||||
|
||||
354
mobile/android/app/src/main/kotlin/app/alextran/immich/media/AssetMedia.g.kt
generated
Normal file
354
mobile/android/app/src/main/kotlin/app/alextran/immich/media/AssetMedia.g.kt
generated
Normal file
@@ -0,0 +1,354 @@
|
||||
// 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.media
|
||||
|
||||
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 AssetMediaPigeonUtils {
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun doubleEquals(a: Double, b: Double): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun floatEquals(a: Float, b: Float): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun doubleHash(d: Double): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (d == 0.0) 0.0 else d
|
||||
val bits = java.lang.Double.doubleToLongBits(normalized)
|
||||
return (bits xor (bits ushr 32)).toInt()
|
||||
}
|
||||
|
||||
fun floatHash(f: Float): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (f == 0.0f) 0.0f else f
|
||||
return java.lang.Float.floatToIntBits(normalized)
|
||||
}
|
||||
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return false
|
||||
}
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!doubleEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is FloatArray && b is FloatArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!floatEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!deepEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
if (a.size != b.size) return false
|
||||
val iterA = a.iterator()
|
||||
val iterB = b.iterator()
|
||||
while (iterA.hasNext() && iterB.hasNext()) {
|
||||
if (!deepEquals(iterA.next(), iterB.next())) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
if (a.size != b.size) return false
|
||||
for (entry in a) {
|
||||
val key = entry.key
|
||||
var found = false
|
||||
for (bEntry in b) {
|
||||
if (deepEquals(key, bEntry.key)) {
|
||||
if (deepEquals(entry.value, bEntry.value)) {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Double && b is Double) {
|
||||
return doubleEquals(a, b)
|
||||
}
|
||||
if (a is Float && b is Float) {
|
||||
return floatEquals(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
fun deepHash(value: Any?): Int {
|
||||
return when (value) {
|
||||
null -> 0
|
||||
is ByteArray -> value.contentHashCode()
|
||||
is IntArray -> value.contentHashCode()
|
||||
is LongArray -> value.contentHashCode()
|
||||
is DoubleArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + doubleHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is FloatArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + floatHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Array<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is List<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Map<*, *> -> {
|
||||
var result = 0
|
||||
for (entry in value) {
|
||||
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
|
||||
}
|
||||
result
|
||||
}
|
||||
is Double -> doubleHash(value)
|
||||
is Float -> floatHash(value)
|
||||
else -> value.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
enum class AssetMediaActionStatus(val raw: Int) {
|
||||
DONE(0),
|
||||
ALREADY_IN_STATE(1),
|
||||
NOT_FOUND(2),
|
||||
FAILED(3);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): AssetMediaActionStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class AssetMediaActionResult (
|
||||
val id: String,
|
||||
val status: AssetMediaActionStatus
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): AssetMediaActionResult {
|
||||
val id = pigeonVar_list[0] as String
|
||||
val status = pigeonVar_list[1] as AssetMediaActionStatus
|
||||
return AssetMediaActionResult(id, status)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
id,
|
||||
status,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as AssetMediaActionResult
|
||||
return AssetMediaPigeonUtils.deepEquals(this.id, other.id) && AssetMediaPigeonUtils.deepEquals(this.status, other.status)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + AssetMediaPigeonUtils.deepHash(this.id)
|
||||
result = 31 * result + AssetMediaPigeonUtils.deepHash(this.status)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class AssetMediaPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
AssetMediaActionStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
AssetMediaActionResult.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is AssetMediaActionStatus -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is AssetMediaActionResult -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface AssetMediaApi {
|
||||
fun trash(ids: List<String>, callback: (Result<List<AssetMediaActionResult>>) -> Unit)
|
||||
fun restore(ids: List<String>, callback: (Result<List<AssetMediaActionResult>>) -> Unit)
|
||||
fun trashedAmong(ids: List<String>, callback: (Result<List<String>>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by AssetMediaApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
AssetMediaPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `AssetMediaApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: AssetMediaApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trash$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val idsArg = args[0] as List<String>
|
||||
api.trash(idsArg) { result: Result<List<AssetMediaActionResult>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(AssetMediaPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(AssetMediaPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AssetMediaApi.restore$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val idsArg = args[0] as List<String>
|
||||
api.restore(idsArg) { result: Result<List<AssetMediaActionResult>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(AssetMediaPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(AssetMediaPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trashedAmong$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val idsArg = args[0] as List<String>
|
||||
api.trashedAmong(idsArg) { result: Result<List<String>> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(AssetMediaPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(AssetMediaPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package app.alextran.immich.media
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private enum class MediaAction { TRASH, RESTORE }
|
||||
|
||||
private data class MediaItem(val uri: Uri, val isTrashed: Boolean)
|
||||
|
||||
private const val MAX_QUERY_ARGS = 900
|
||||
|
||||
private const val MAX_CONSENT_URIS = 500
|
||||
|
||||
@SuppressLint("NewApi", "InlinedApi")
|
||||
class AssetMediaApiImpl(context: Context) : ImmichPlugin(), AssetMediaApi, ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
private var binaryMessenger: BinaryMessenger? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private val supportsMediaRequest: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
super.onAttachedToEngine(binding)
|
||||
binaryMessenger = binding.binaryMessenger
|
||||
AssetMediaApi.setUp(binding.binaryMessenger, this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
super.onDetachedFromEngine(binding)
|
||||
binaryMessenger?.let { AssetMediaApi.setUp(it, null) }
|
||||
binaryMessenger = null
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun trash(ids: List<String>, callback: (Result<List<AssetMediaActionResult>>) -> Unit) =
|
||||
runAction(ids, MediaAction.TRASH, callback)
|
||||
|
||||
override fun restore(
|
||||
ids: List<String>,
|
||||
callback: (Result<List<AssetMediaActionResult>>) -> Unit
|
||||
) =
|
||||
runAction(ids, MediaAction.RESTORE, callback)
|
||||
|
||||
override fun trashedAmong(ids: List<String>, callback: (Result<List<String>>) -> Unit) =
|
||||
respond(callback, "TRASHED_AMONG_ERROR") {
|
||||
if (!supportsMediaRequest) return@respond emptyList()
|
||||
val items = queryMediaItems(ids)
|
||||
ids.filter { items[it]?.isTrashed == true }
|
||||
}
|
||||
|
||||
private fun runAction(
|
||||
ids: List<String>,
|
||||
action: MediaAction,
|
||||
callback: (Result<List<AssetMediaActionResult>>) -> Unit,
|
||||
) = respond(callback, "MEDIA_ACTION_ERROR") {
|
||||
if (ids.isEmpty()) return@respond emptyList()
|
||||
if (!supportsMediaRequest) {
|
||||
return@respond ids.map { AssetMediaActionResult(it, AssetMediaActionStatus.FAILED) }
|
||||
}
|
||||
|
||||
val items = queryMediaItems(ids)
|
||||
|
||||
val targets = ids.mapNotNull { id ->
|
||||
val item = items[id] ?: return@mapNotNull null
|
||||
val needsAction = when (action) {
|
||||
MediaAction.TRASH -> !item.isTrashed
|
||||
MediaAction.RESTORE -> item.isTrashed
|
||||
}
|
||||
|
||||
if (needsAction) id to item.uri else null
|
||||
}.toMap()
|
||||
|
||||
val applied = buildMap {
|
||||
for (batch in targets.entries.chunked(MAX_CONSENT_URIS)) {
|
||||
val result = if (requestConsent(action, batch.map { it.value })) {
|
||||
AssetMediaActionStatus.DONE
|
||||
} else {
|
||||
AssetMediaActionStatus.FAILED
|
||||
}
|
||||
batch.forEach { put(it.key, result) }
|
||||
}
|
||||
}
|
||||
|
||||
ids.map { id ->
|
||||
val status = when (id) {
|
||||
in applied -> applied.getValue(id)
|
||||
!in items -> AssetMediaActionStatus.NOT_FOUND
|
||||
else -> AssetMediaActionStatus.ALREADY_IN_STATE
|
||||
}
|
||||
AssetMediaActionResult(id, status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun <T> respond(callback: (Result<T>) -> Unit, errorCode: String, work: suspend () -> T) {
|
||||
scope.launch {
|
||||
try {
|
||||
completeWhenActive(callback, Result.success(work()))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
completeWhenActive(callback, Result.failure(FlutterError(errorCode, e.message, null)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestConsent(action: MediaAction, uris: List<Uri>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
val activity = activityBinding?.activity as? ComponentActivity ?: return@withContext false
|
||||
val resolver = ctx.contentResolver
|
||||
val request = when (action) {
|
||||
MediaAction.TRASH -> MediaStore.createTrashRequest(resolver, uris, true)
|
||||
MediaAction.RESTORE -> MediaStore.createTrashRequest(resolver, uris, false)
|
||||
}
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val key = "immich_asset_media_api_${UUID.randomUUID()}"
|
||||
var launcher: ActivityResultLauncher<IntentSenderRequest>? = null
|
||||
launcher = activity.activityResultRegistry.register(
|
||||
key,
|
||||
ActivityResultContracts.StartIntentSenderForResult()
|
||||
) { result ->
|
||||
launcher?.unregister()
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result.resultCode == Activity.RESULT_OK)
|
||||
}
|
||||
}
|
||||
continuation.invokeOnCancellation { launcher.unregister() }
|
||||
try {
|
||||
launcher.launch(IntentSenderRequest.Builder(request.intentSender).build())
|
||||
} catch (_: Exception) {
|
||||
launcher.unregister()
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryMediaItems(ids: List<String>): Map<String, MediaItem> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val numeric = ids.filter { it.toLongOrNull() != null }
|
||||
if (numeric.isEmpty()) {
|
||||
return@withContext emptyMap()
|
||||
}
|
||||
|
||||
buildMap {
|
||||
for (chunk in numeric.chunked(MAX_QUERY_ARGS)) {
|
||||
ensureActive()
|
||||
val placeholders = chunk.joinToString(",") { "?" }
|
||||
val args = Bundle().apply {
|
||||
putString(
|
||||
ContentResolver.QUERY_ARG_SQL_SELECTION,
|
||||
"${MediaStore.Files.FileColumns._ID} IN ($placeholders)"
|
||||
)
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, chunk.toTypedArray())
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE)
|
||||
}
|
||||
|
||||
ctx.contentResolver.query(
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
arrayOf(
|
||||
MediaStore.Files.FileColumns._ID,
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE,
|
||||
MediaStore.Files.FileColumns.IS_TRASHED,
|
||||
),
|
||||
args,
|
||||
null,
|
||||
)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
|
||||
val typeColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
val trashedColumn =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.IS_TRASHED)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val uri = mediaContentUri(cursor.getInt(typeColumn), id) ?: continue
|
||||
put(id.toString(), MediaItem(uri, cursor.getInt(trashedColumn) == 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaContentUri(mediaType: Int, id: Long): Uri? = when (mediaType) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE ->
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
|
||||
)
|
||||
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
|
||||
ContentUris.withAppendedId(
|
||||
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
id
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
@@ -553,8 +553,6 @@ interface NativeSyncApi {
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
fun cancelSync()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
|
||||
companion object {
|
||||
@@ -765,42 +763,6 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.getTrashedAssets())
|
||||
} catch (exception: Throwable) {
|
||||
MessagesPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
|
||||
@@ -29,9 +29,4 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
|
||||
private fun getMediaChanges(): SyncDelta {
|
||||
throw IllegalStateException("Method not supported on this Android version.")
|
||||
}
|
||||
|
||||
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||
//Method not supported on this Android version.
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,29 +99,4 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
||||
// Unmounted volumes are handled in dart when the album is removed
|
||||
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
|
||||
}
|
||||
|
||||
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||
|
||||
val result = LinkedHashMap<String, MutableList<PlatformAsset>>()
|
||||
val volumes = MediaStore.getExternalVolumeNames(ctx)
|
||||
|
||||
for (volume in volumes) {
|
||||
|
||||
val queryArgs = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION)
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS)
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
}
|
||||
|
||||
getCursor(volume, queryArgs).use { cursor ->
|
||||
getAssets(cursor).forEach { res ->
|
||||
if (res is AssetResult.ValidAsset) {
|
||||
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.mapValues { it.value.toList() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ext.SdkExtensions
|
||||
@@ -17,8 +16,6 @@ import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -41,12 +38,11 @@ sealed class AssetResult {
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
private var syncJob: Job? = null
|
||||
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||
@@ -377,7 +373,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
)?.use { cursor -> cursor.count.toLong() } ?: 0L
|
||||
|
||||
|
||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit) {
|
||||
fun getAssetsForAlbum(
|
||||
albumId: String,
|
||||
updatedTimeCond: Long?,
|
||||
callback: (Result<List<PlatformAsset>>) -> Unit
|
||||
) {
|
||||
runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
|
||||
}
|
||||
|
||||
@@ -477,33 +477,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
try {
|
||||
completeWhenActive(callback, Result.success(work()))
|
||||
} catch (e: CancellationException) {
|
||||
completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)))
|
||||
completeWhenActive(
|
||||
callback,
|
||||
Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
completeWhenActive(callback, Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
|
||||
3550
mobile/drift_schemas/main/drift_schema_v32.json
generated
Normal file
3550
mobile/drift_schemas/main/drift_schema_v32.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -161,6 +161,11 @@
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2D5F7B13005FCA00080CE3E /* AssetMedia */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = AssetMedia;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@@ -279,6 +284,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2D5F7B13005FCA00080CE3E /* AssetMedia */,
|
||||
FE1BB4562F8319560087DBF9 /* Utility */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
@@ -381,6 +387,7 @@
|
||||
fileSystemSynchronizedGroups = (
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
B2D5F7B13005FCA00080CE3E /* AssetMedia */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
);
|
||||
name = Runner;
|
||||
|
||||
@@ -26,6 +26,7 @@ import native_video_player
|
||||
|
||||
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
||||
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||
AssetMediaApiImpl.register(with: registry.registrar(forPlugin: AssetMediaApiImpl.name)!)
|
||||
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
|
||||
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
||||
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
||||
@@ -36,5 +37,6 @@ import native_video_player
|
||||
|
||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
|
||||
(engine.valuePublished(byPlugin: AssetMediaApiImpl.name) as? AssetMediaApiImpl)?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
|
||||
313
mobile/ios/Runner/AssetMedia/AssetMedia.g.swift
generated
Normal file
313
mobile/ios/Runner/AssetMedia/AssetMedia.g.swift
generated
Normal file
@@ -0,0 +1,313 @@
|
||||
// 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?
|
||||
}
|
||||
|
||||
private func doubleEqualsAssetMedia(_ lhs: Double, _ rhs: Double) -> Bool {
|
||||
return (lhs.isNaN && rhs.isNaN) || lhs == rhs
|
||||
}
|
||||
|
||||
private func doubleHashAssetMedia(_ value: Double, _ hasher: inout Hasher) {
|
||||
if value.isNaN {
|
||||
hasher.combine(0x7FF8000000000000)
|
||||
} else {
|
||||
// Normalize -0.0 to 0.0
|
||||
hasher.combine(value == 0 ? 0 : value)
|
||||
}
|
||||
}
|
||||
|
||||
func deepEqualsAssetMedia(_ lhs: Any?, _ rhs: Any?) -> Bool {
|
||||
let cleanLhs = nilOrValue(lhs) as Any?
|
||||
let cleanRhs = nilOrValue(rhs) as Any?
|
||||
switch (cleanLhs, cleanRhs) {
|
||||
case (nil, nil):
|
||||
return true
|
||||
|
||||
case (nil, _), (_, nil):
|
||||
return false
|
||||
|
||||
case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs:
|
||||
return true
|
||||
|
||||
case is (Void, Void):
|
||||
return true
|
||||
|
||||
case (let lhsArray, let rhsArray) as ([Any?], [Any?]):
|
||||
guard lhsArray.count == rhsArray.count else { return false }
|
||||
for (index, element) in lhsArray.enumerated() {
|
||||
if !deepEqualsAssetMedia(element, rhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case (let lhsArray, let rhsArray) as ([Double], [Double]):
|
||||
guard lhsArray.count == rhsArray.count else { return false }
|
||||
for (index, element) in lhsArray.enumerated() {
|
||||
if !doubleEqualsAssetMedia(element, rhsArray[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
|
||||
guard lhsDictionary.count == rhsDictionary.count else { return false }
|
||||
for (lhsKey, lhsValue) in lhsDictionary {
|
||||
var found = false
|
||||
for (rhsKey, rhsValue) in rhsDictionary {
|
||||
if deepEqualsAssetMedia(lhsKey, rhsKey) {
|
||||
if deepEqualsAssetMedia(lhsValue, rhsValue) {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found { return false }
|
||||
}
|
||||
return true
|
||||
|
||||
case (let lhs as Double, let rhs as Double):
|
||||
return doubleEqualsAssetMedia(lhs, rhs)
|
||||
|
||||
case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable):
|
||||
return lhsHashable == rhsHashable
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func deepHashAssetMedia(value: Any?, hasher: inout Hasher) {
|
||||
let cleanValue = nilOrValue(value) as Any?
|
||||
if let cleanValue = cleanValue {
|
||||
if let doubleValue = cleanValue as? Double {
|
||||
doubleHashAssetMedia(doubleValue, &hasher)
|
||||
} else if let valueList = cleanValue as? [Any?] {
|
||||
for item in valueList {
|
||||
deepHashAssetMedia(value: item, hasher: &hasher)
|
||||
}
|
||||
} else if let valueList = cleanValue as? [Double] {
|
||||
for item in valueList {
|
||||
doubleHashAssetMedia(item, &hasher)
|
||||
}
|
||||
} else if let valueDict = cleanValue as? [AnyHashable: Any?] {
|
||||
var result = 0
|
||||
for (key, value) in valueDict {
|
||||
var entryKeyHasher = Hasher()
|
||||
deepHashAssetMedia(value: key, hasher: &entryKeyHasher)
|
||||
var entryValueHasher = Hasher()
|
||||
deepHashAssetMedia(value: value, hasher: &entryValueHasher)
|
||||
result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize())
|
||||
}
|
||||
hasher.combine(result)
|
||||
} else if let hashableValue = cleanValue as? AnyHashable {
|
||||
hasher.combine(hashableValue)
|
||||
} else {
|
||||
hasher.combine(String(describing: cleanValue))
|
||||
}
|
||||
} else {
|
||||
hasher.combine(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum AssetMediaActionStatus: Int {
|
||||
case done = 0
|
||||
case alreadyInState = 1
|
||||
case notFound = 2
|
||||
case failed = 3
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct AssetMediaActionResult: Hashable {
|
||||
var id: String
|
||||
var status: AssetMediaActionStatus
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> AssetMediaActionResult? {
|
||||
let id = pigeonVar_list[0] as! String
|
||||
let status = pigeonVar_list[1] as! AssetMediaActionStatus
|
||||
|
||||
return AssetMediaActionResult(
|
||||
id: id,
|
||||
status: status
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
id,
|
||||
status,
|
||||
]
|
||||
}
|
||||
static func == (lhs: AssetMediaActionResult, rhs: AssetMediaActionResult) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsAssetMedia(lhs.id, rhs.id) && deepEqualsAssetMedia(lhs.status, rhs.status)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("AssetMediaActionResult")
|
||||
deepHashAssetMedia(value: id, hasher: &hasher)
|
||||
deepHashAssetMedia(value: status, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class AssetMediaPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
case 129:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return AssetMediaActionStatus(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
return AssetMediaActionResult.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AssetMediaPigeonCodecWriter: FlutterStandardWriter {
|
||||
override func writeValue(_ value: Any) {
|
||||
if let value = value as? AssetMediaActionStatus {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? AssetMediaActionResult {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AssetMediaPigeonCodecReaderWriter: FlutterStandardReaderWriter {
|
||||
override func reader(with data: Data) -> FlutterStandardReader {
|
||||
return AssetMediaPigeonCodecReader(data: data)
|
||||
}
|
||||
|
||||
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
|
||||
return AssetMediaPigeonCodecWriter(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
class AssetMediaPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
||||
static let shared = AssetMediaPigeonCodec(readerWriter: AssetMediaPigeonCodecReaderWriter())
|
||||
}
|
||||
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol AssetMediaApi {
|
||||
func trash(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void)
|
||||
func restore(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void)
|
||||
func trashedAmong(ids: [String], completion: @escaping (Result<[String], Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class AssetMediaApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { AssetMediaPigeonCodec.shared }
|
||||
/// Sets up an instance of `AssetMediaApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: AssetMediaApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let trashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
trashChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let idsArg = args[0] as! [String]
|
||||
api.trash(ids: idsArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trashChannel.setMessageHandler(nil)
|
||||
}
|
||||
let restoreChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AssetMediaApi.restore\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
restoreChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let idsArg = args[0] as! [String]
|
||||
api.restore(ids: idsArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreChannel.setMessageHandler(nil)
|
||||
}
|
||||
let trashedAmongChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AssetMediaApi.trashedAmong\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
trashedAmongChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let idsArg = args[0] as! [String]
|
||||
api.trashedAmong(ids: idsArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trashedAmongChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
mobile/ios/Runner/AssetMedia/AssetMediaApiImpl.swift
Normal file
26
mobile/ios/Runner/AssetMedia/AssetMediaApiImpl.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
class AssetMediaApiImpl: ImmichPlugin, AssetMediaApi, FlutterPlugin {
|
||||
static let name = "AssetMediaApi"
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let instance = AssetMediaApiImpl()
|
||||
AssetMediaApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
||||
registrar.publish(instance)
|
||||
}
|
||||
|
||||
func detachFromEngine(for registrar: FlutterPluginRegistrar) {
|
||||
AssetMediaApiSetup.setUp(binaryMessenger: registrar.messenger(), api: nil)
|
||||
super.detachFromEngine()
|
||||
}
|
||||
|
||||
func trash(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void) {
|
||||
completeWhenActive(for: completion, with: .success(ids.map { AssetMediaActionResult(id: $0, status: .failed) }))
|
||||
}
|
||||
|
||||
func restore(ids: [String], completion: @escaping (Result<[AssetMediaActionResult], Error>) -> Void) {
|
||||
completeWhenActive(for: completion, with: .success(ids.map { AssetMediaActionResult(id: $0, status: .failed) }))
|
||||
}
|
||||
|
||||
func trashedAmong(ids: [String], completion: @escaping (Result<[String], Error>) -> Void) {
|
||||
completeWhenActive(for: completion, with: .success([]))
|
||||
}
|
||||
}
|
||||
35
mobile/ios/Runner/Sync/Messages.g.swift
generated
35
mobile/ios/Runner/Sync/Messages.g.swift
generated
@@ -537,8 +537,6 @@ protocol NativeSyncApi {
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func cancelSync() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
}
|
||||
|
||||
@@ -723,39 +721,6 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
cancelSyncChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getTrashedAssetsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getTrashedAssetsChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.getTrashedAssets()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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
|
||||
? 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)
|
||||
|
||||
@@ -436,14 +436,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
})
|
||||
}
|
||||
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||
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> {
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||
|
||||
@@ -26,3 +26,17 @@ enum SlideshowLook { contain, cover, blurredBackground }
|
||||
enum SlideshowDirection { forward, backward, shuffle }
|
||||
|
||||
enum PartnerDirection { sharedBy, sharedWith }
|
||||
|
||||
enum TrashSyncStatus {
|
||||
pending,
|
||||
trashed,
|
||||
/* The asset was restored outside of Immich. The implications of this are:
|
||||
* - Assets matching the same checksum won't be re-trashed
|
||||
* - Backups will ignore the asset from re-uploading
|
||||
*/
|
||||
dismissed,
|
||||
/* The asset was restored back out of the OS trash by us. This allows us
|
||||
* to copy the checksum back into the local asset table
|
||||
*/
|
||||
restored,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const defaultConfig = AppConfig();
|
||||
|
||||
class AppConfig {
|
||||
final LogLevel logLevel;
|
||||
final bool trashSyncEnabled;
|
||||
final ThemeConfig theme;
|
||||
final CleanupConfig cleanup;
|
||||
final MapConfig map;
|
||||
@@ -38,6 +39,7 @@ class AppConfig {
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
this.trashSyncEnabled = false,
|
||||
this.theme = const .new(),
|
||||
this.cleanup = const .new(),
|
||||
this.map = const .new(),
|
||||
@@ -54,6 +56,7 @@ class AppConfig {
|
||||
|
||||
AppConfig copyWith({
|
||||
LogLevel? logLevel,
|
||||
bool? trashSyncEnabled,
|
||||
ThemeConfig? theme,
|
||||
CleanupConfig? cleanup,
|
||||
MapConfig? map,
|
||||
@@ -68,6 +71,7 @@ class AppConfig {
|
||||
FeatureMessageConfig? featureMessage,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
trashSyncEnabled: trashSyncEnabled ?? this.trashSyncEnabled,
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
map: map ?? this.map,
|
||||
@@ -87,6 +91,7 @@ class AppConfig {
|
||||
identical(this, other) ||
|
||||
(other is AppConfig &&
|
||||
other.logLevel == logLevel &&
|
||||
other.trashSyncEnabled == trashSyncEnabled &&
|
||||
other.theme == theme &&
|
||||
other.cleanup == cleanup &&
|
||||
other.map == map &&
|
||||
@@ -103,6 +108,7 @@ class AppConfig {
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
logLevel,
|
||||
trashSyncEnabled,
|
||||
theme,
|
||||
cleanup,
|
||||
map,
|
||||
@@ -119,11 +125,12 @@ class AppConfig {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
|
||||
'AppConfig(logLevel: $logLevel, trashSyncEnabled: $trashSyncEnabled, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
|
||||
|
||||
T read<T>(SettingsKey<T> key) =>
|
||||
(switch (key) {
|
||||
.logLevel => logLevel,
|
||||
.trashSyncEnabled => trashSyncEnabled,
|
||||
.themePrimaryColor => theme.primaryColor,
|
||||
.themeMode => theme.mode,
|
||||
.themeDynamic => theme.dynamicTheme,
|
||||
@@ -176,6 +183,7 @@ class AppConfig {
|
||||
AppConfig write<T, U extends T>(SettingsKey<T> key, U value) {
|
||||
return switch (key) {
|
||||
.logLevel => copyWith(logLevel: value as LogLevel),
|
||||
.trashSyncEnabled => copyWith(trashSyncEnabled: value as bool),
|
||||
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
|
||||
.themeMode => copyWith(theme: theme.copyWith(mode: value as ThemeMode)),
|
||||
.themeDynamic => copyWith(theme: theme.copyWith(dynamicTheme: value as bool)),
|
||||
|
||||
@@ -37,6 +37,9 @@ enum SettingsKey<T> {
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
|
||||
// Sync
|
||||
trashSyncEnabled<bool>(),
|
||||
|
||||
// Backup
|
||||
backupEnabled<bool>(),
|
||||
backupUseCellularForVideos<bool>(),
|
||||
|
||||
@@ -12,13 +12,13 @@ enum StoreKey<T> {
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyManageLocalMediaAndroid<bool>._(137),
|
||||
legacyBackupRequireCharging<bool>._(7),
|
||||
legacyBackupTriggerDelay<int>._(8),
|
||||
legacySyncAlbums<bool>._(131),
|
||||
|
||||
@@ -4,10 +4,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -17,7 +15,6 @@ class HashService {
|
||||
final int _batchSize;
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final Completer<void>? _cancellation;
|
||||
final _log = Logger('HashService');
|
||||
@@ -25,7 +22,6 @@ class HashService {
|
||||
HashService({
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
this._cancellation,
|
||||
int? batchSize,
|
||||
@@ -58,14 +54,6 @@ class HashService {
|
||||
await _hashAssets(album, assetsToHash);
|
||||
}
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||
final backupAlbumIds = localAlbums.map((e) => e.id);
|
||||
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||
if (trashedToHash.isNotEmpty) {
|
||||
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
}
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -82,7 +70,7 @@ class HashService {
|
||||
/// 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
|
||||
/// [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) async {
|
||||
final toHash = <String, LocalAsset>{};
|
||||
|
||||
for (final asset in assetsToHash) {
|
||||
@@ -93,16 +81,16 @@ class HashService {
|
||||
|
||||
toHash[asset.id] = asset;
|
||||
if (toHash.length == _batchSize) {
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
await _processBatch(album, toHash);
|
||||
toHash.clear();
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
await _processBatch(album, toHash);
|
||||
}
|
||||
|
||||
/// 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) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -137,10 +125,6 @@ class HashService {
|
||||
}
|
||||
|
||||
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||
if (isTrashed) {
|
||||
await _trashedLocalAssetRepository.updateHashes(hashed);
|
||||
} else {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,11 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.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/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -24,20 +20,16 @@ class LocalSyncService {
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
// ignore: unused_field
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final Completer<void>? _cancellation;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashSyncRepository,
|
||||
required this._nativeSyncApi,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
this._cancellation,
|
||||
}) {
|
||||
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
|
||||
@@ -48,15 +40,6 @@ class LocalSyncService {
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
} else {
|
||||
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
}
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
// final assets = await _localAssetRepository.getEmptyCloudIdAssets();
|
||||
// await _mapIosCloudIds(assets);
|
||||
@@ -119,6 +102,7 @@ class LocalSyncService {
|
||||
await _mapIosCloudIds(newAssets);
|
||||
}
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
await _trashSyncRepository.restoreChecksums();
|
||||
} on PlatformException catch (e, s) {
|
||||
if (e.code == _kSyncCancelledCode) {
|
||||
_log.warning("Local sync cancelled");
|
||||
@@ -150,6 +134,7 @@ class LocalSyncService {
|
||||
);
|
||||
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
await _trashSyncRepository.restoreChecksums();
|
||||
stopwatch.stop();
|
||||
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
} on PlatformException catch (e, s) {
|
||||
@@ -383,48 +368,6 @@ class LocalSyncService {
|
||||
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
|
||||
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
|
||||
}
|
||||
|
||||
Future<void> _syncTrashedAssets() async {
|
||||
final trashedAssetMap = await _nativeSyncApi.getTrashedAssets();
|
||||
await processTrashedAssets(trashedAssetMap);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
|
||||
if (trashedAssetMap.isEmpty) {
|
||||
_log.info("syncTrashedAssets, No trashed assets found");
|
||||
}
|
||||
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
|
||||
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
|
||||
);
|
||||
|
||||
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
|
||||
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
|
||||
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||
}
|
||||
|
||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_log.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<PlatformAlbum> {
|
||||
@@ -445,10 +388,6 @@ extension on Iterable<PlatformAsset> {
|
||||
List<LocalAsset> toLocalAssets() {
|
||||
return map((e) => e.toLocalAsset()).toList();
|
||||
}
|
||||
|
||||
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
|
||||
return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
|
||||
}
|
||||
}
|
||||
|
||||
extension PlatformToLocalAsset on PlatformAsset {
|
||||
|
||||
@@ -3,18 +3,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
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/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.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/sync_api.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/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -33,10 +28,7 @@ class SyncStreamService {
|
||||
|
||||
final SyncApiRepository _syncApiRepository;
|
||||
final SyncStreamRepository _syncStreamRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final SyncMigrationRepository _syncMigrationRepository;
|
||||
final ApiService _api;
|
||||
final Completer<void>? _cancellation;
|
||||
@@ -44,10 +36,7 @@ class SyncStreamService {
|
||||
SyncStreamService({
|
||||
required this._syncApiRepository,
|
||||
required this._syncStreamRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
required this._trashSyncRepository,
|
||||
required this._syncMigrationRepository,
|
||||
required this._api,
|
||||
this._cancellation,
|
||||
@@ -202,24 +191,13 @@ class SyncStreamService {
|
||||
case SyncEntityType.partnerDeleteV1:
|
||||
return _syncStreamRepository.deletePartnerV1(data.cast());
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
return;
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast<SyncAssetV1>());
|
||||
case SyncEntityType.assetV2:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV2>();
|
||||
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
return;
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast<SyncAssetV2>());
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
|
||||
}
|
||||
// Has to be before the delete call so we can record the checksums of the assets as well
|
||||
await _trashSyncRepository.recordHardDeletedChecksums(remoteSyncAssets.map((e) => e.assetId));
|
||||
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
|
||||
case SyncEntityType.assetExifV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||
@@ -499,59 +477,4 @@ class SyncStreamService {
|
||||
_logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return Future.value();
|
||||
} else {
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
await _trashLocalAssets(localAssetsToTrash);
|
||||
} else {
|
||||
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_logger.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
await _applyRemoteRestoreToLocal();
|
||||
}
|
||||
|
||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
}
|
||||
}
|
||||
|
||||
124
mobile/lib/domain/services/trash_sync.service.dart
Normal file
124
mobile/lib/domain/services/trash_sync.service.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TrashSyncService {
|
||||
final DriftTrashSyncRepository _repo;
|
||||
final AssetMediaApi _assetMediaApi;
|
||||
final PermissionRepository _permission;
|
||||
final SettingsRepository _settings;
|
||||
final Logger _log = Logger('TrashSyncService');
|
||||
|
||||
TrashSyncService({
|
||||
required this._repo,
|
||||
required this._assetMediaApi,
|
||||
required this._permission,
|
||||
required this._settings,
|
||||
});
|
||||
|
||||
Future<void> reconcile() async {
|
||||
try {
|
||||
await _prune();
|
||||
|
||||
if (!_settings.appConfig.trashSyncEnabled || !await _canApplyToOsTrash()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _record();
|
||||
await _act();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Trash reconcile failed", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prune() async {
|
||||
await _repo.pruneStaleMarkers();
|
||||
await _repo.pruneDismissedMarkers();
|
||||
await _repo.prunePendingMarkers();
|
||||
}
|
||||
|
||||
Future<void> _record() async {
|
||||
await _repo.recordSoftDeleteAssets();
|
||||
await _repo.recordHardDeletedAssets();
|
||||
}
|
||||
|
||||
Future<void> _act() async {
|
||||
await _trashAssets();
|
||||
await _restoreAssets();
|
||||
await _reconcileWithOSTrash();
|
||||
}
|
||||
|
||||
Future<void> _trashAssets() async {
|
||||
final pending = await _repo.getPendingAssetIds();
|
||||
if (pending.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final results = await _assetMediaApi.trash(pending);
|
||||
final movedIds = results.whereStatusIn(const {.done, .alreadyInState});
|
||||
final removedIds = results.whereStatusIn(const {.notFound});
|
||||
|
||||
if (movedIds.isNotEmpty) {
|
||||
await _repo.markTrashed(movedIds);
|
||||
}
|
||||
|
||||
if (removedIds.isNotEmpty) {
|
||||
await _repo.deleteMarkers(removedIds);
|
||||
}
|
||||
|
||||
_log.fine("Trashed ${movedIds.length}, dropped ${removedIds.length} out of ${pending.length} pending");
|
||||
}
|
||||
|
||||
Future<void> _restoreAssets() async {
|
||||
final restorable = await _repo.getRestorableAssetIds();
|
||||
if (restorable.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final results = await _assetMediaApi.restore(restorable);
|
||||
final restoredIds = results.whereStatusIn(const {.done, .alreadyInState});
|
||||
final goneIds = results.whereStatusIn(const {.notFound});
|
||||
|
||||
if (restoredIds.isNotEmpty) {
|
||||
await _repo.markRestored(restoredIds);
|
||||
}
|
||||
|
||||
if (goneIds.isNotEmpty) {
|
||||
await _repo.deleteMarkers(goneIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconcileWithOSTrash() async {
|
||||
final trashed = await _repo.getTrashedAssetIds();
|
||||
if (trashed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final stillTrashed = (await _assetMediaApi.trashedAmong(trashed)).toSet();
|
||||
final gone = trashed.where((id) => !stillTrashed.contains(id)).toList();
|
||||
if (gone.isNotEmpty) {
|
||||
await _repo.reconcileTrashed(gone);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _canApplyToOsTrash() async {
|
||||
if (!CurrentPlatform.isAndroid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasPermission = await _permission.hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
_log.fine("OS trash sync skipped: MANAGE_MEDIA permission not granted");
|
||||
}
|
||||
|
||||
return hasPermission;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<AssetMediaActionResult> {
|
||||
Set<String> whereStatusIn(Set<AssetMediaActionStatus> statuses) =>
|
||||
where((r) => statuses.contains(r.status)).map((r) => r.id).toSet();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
|
||||
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/utils/isolate.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
@@ -33,6 +34,7 @@ class BackgroundSyncManager {
|
||||
Cancelable<void>? _deviceAlbumSyncTask;
|
||||
Cancelable<void>? _linkedAlbumSyncTask;
|
||||
Cancelable<void>? _hashTask;
|
||||
Cancelable<void>? _trashSyncTask;
|
||||
|
||||
BackgroundSyncManager({
|
||||
this.onRemoteSyncStart,
|
||||
@@ -57,6 +59,7 @@ class BackgroundSyncManager {
|
||||
_linkedAlbumSyncTask,
|
||||
_deviceAlbumSyncTask,
|
||||
_hashTask,
|
||||
_trashSyncTask,
|
||||
];
|
||||
final futures = [
|
||||
for (final task in tasks)
|
||||
@@ -71,6 +74,7 @@ class BackgroundSyncManager {
|
||||
_linkedAlbumSyncTask = null;
|
||||
_deviceAlbumSyncTask = null;
|
||||
_hashTask = null;
|
||||
_trashSyncTask = null;
|
||||
|
||||
try {
|
||||
await Future.wait(futures);
|
||||
@@ -160,6 +164,21 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncTrash() {
|
||||
if (_trashSyncTask != null) {
|
||||
return _trashSyncTask!.future;
|
||||
}
|
||||
|
||||
_trashSyncTask = runInIsolateGentle(
|
||||
computation: (ref) => ref.read(trashSyncServiceProvider).reconcile(),
|
||||
debugLabel: 'trash-sync',
|
||||
);
|
||||
|
||||
return _trashSyncTask!.whenComplete(() {
|
||||
_trashSyncTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
|
||||
@@ -16,6 +16,11 @@ ON remote_asset_entity (owner_id, library_id, checksum)
|
||||
WHERE (library_id IS NOT NULL);
|
||||
''')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)')
|
||||
@TableIndex.sql('''
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum
|
||||
ON remote_asset_entity (checksum)
|
||||
WHERE deleted_at IS NOT NULL
|
||||
''')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)')
|
||||
@TableIndex.sql('''
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created
|
||||
|
||||
@@ -1771,6 +1771,10 @@ i0.Index get idxRemoteAssetChecksum => i0.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
i0.Index get idxRemoteAssetSoftDeletedChecksum => i0.Index(
|
||||
'idx_remote_asset_soft_deleted_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum ON remote_asset_entity (checksum) WHERE deleted_at IS NOT NULL',
|
||||
);
|
||||
i0.Index get idxRemoteAssetStackId => i0.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class ServerDeletedChecksumEntity extends Table with DriftDefaultsMixin {
|
||||
const ServerDeletedChecksumEntity();
|
||||
|
||||
TextColumn get checksum => text()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {checksum};
|
||||
|
||||
@override
|
||||
String get tableName => "server_deleted_checksum";
|
||||
}
|
||||
318
mobile/lib/infrastructure/entities/server_deleted_checksum.entity.drift.dart
generated
Normal file
318
mobile/lib/infrastructure/entities/server_deleted_checksum.entity.drift.dart
generated
Normal file
@@ -0,0 +1,318 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.dart'
|
||||
as i2;
|
||||
|
||||
typedef $$ServerDeletedChecksumEntityTableCreateCompanionBuilder =
|
||||
i1.ServerDeletedChecksumEntityCompanion Function({
|
||||
required String checksum,
|
||||
});
|
||||
typedef $$ServerDeletedChecksumEntityTableUpdateCompanionBuilder =
|
||||
i1.ServerDeletedChecksumEntityCompanion Function({
|
||||
i0.Value<String> checksum,
|
||||
});
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableFilterComposer
|
||||
extends
|
||||
i0.Composer<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableOrderingComposer
|
||||
extends
|
||||
i0.Composer<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableAnnotationComposer
|
||||
extends
|
||||
i0.Composer<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get checksum =>
|
||||
$composableBuilder(column: $table.checksum, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$ServerDeletedChecksumEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i1.$$ServerDeletedChecksumEntityTableFilterComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableOrderingComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableAnnotationComposer,
|
||||
$$ServerDeletedChecksumEntityTableCreateCompanionBuilder,
|
||||
$$ServerDeletedChecksumEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData
|
||||
>,
|
||||
),
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$ServerDeletedChecksumEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$ServerDeletedChecksumEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$ServerDeletedChecksumEntityTableFilterComposer(
|
||||
$db: db,
|
||||
$table: table,
|
||||
),
|
||||
createOrderingComposer: () =>
|
||||
i1.$$ServerDeletedChecksumEntityTableOrderingComposer(
|
||||
$db: db,
|
||||
$table: table,
|
||||
),
|
||||
createComputedFieldComposer: () =>
|
||||
i1.$$ServerDeletedChecksumEntityTableAnnotationComposer(
|
||||
$db: db,
|
||||
$table: table,
|
||||
),
|
||||
updateCompanionCallback:
|
||||
({i0.Value<String> checksum = const i0.Value.absent()}) =>
|
||||
i1.ServerDeletedChecksumEntityCompanion(checksum: checksum),
|
||||
createCompanionCallback: ({required String checksum}) =>
|
||||
i1.ServerDeletedChecksumEntityCompanion.insert(
|
||||
checksum: checksum,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$ServerDeletedChecksumEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i1.$$ServerDeletedChecksumEntityTableFilterComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableOrderingComposer,
|
||||
i1.$$ServerDeletedChecksumEntityTableAnnotationComposer,
|
||||
$$ServerDeletedChecksumEntityTableCreateCompanionBuilder,
|
||||
$$ServerDeletedChecksumEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData
|
||||
>,
|
||||
),
|
||||
i1.ServerDeletedChecksumEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $ServerDeletedChecksumEntityTable extends i2.ServerDeletedChecksumEntity
|
||||
with
|
||||
i0.TableInfo<
|
||||
$ServerDeletedChecksumEntityTable,
|
||||
i1.ServerDeletedChecksumEntityData
|
||||
> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$ServerDeletedChecksumEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
|
||||
'checksum',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
|
||||
'checksum',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [checksum];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'server_deleted_checksum';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.ServerDeletedChecksumEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('checksum')) {
|
||||
context.handle(
|
||||
_checksumMeta,
|
||||
checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_checksumMeta);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {checksum};
|
||||
@override
|
||||
i1.ServerDeletedChecksumEntityData map(
|
||||
Map<String, dynamic> data, {
|
||||
String? tablePrefix,
|
||||
}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.ServerDeletedChecksumEntityData(
|
||||
checksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}checksum'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$ServerDeletedChecksumEntityTable createAlias(String alias) {
|
||||
return $ServerDeletedChecksumEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class ServerDeletedChecksumEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.ServerDeletedChecksumEntityData> {
|
||||
final String checksum;
|
||||
const ServerDeletedChecksumEntityData({required this.checksum});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['checksum'] = i0.Variable<String>(checksum);
|
||||
return map;
|
||||
}
|
||||
|
||||
factory ServerDeletedChecksumEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return ServerDeletedChecksumEntityData(
|
||||
checksum: serializer.fromJson<String>(json['checksum']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{'checksum': serializer.toJson<String>(checksum)};
|
||||
}
|
||||
|
||||
i1.ServerDeletedChecksumEntityData copyWith({String? checksum}) =>
|
||||
i1.ServerDeletedChecksumEntityData(checksum: checksum ?? this.checksum);
|
||||
ServerDeletedChecksumEntityData copyWithCompanion(
|
||||
i1.ServerDeletedChecksumEntityCompanion data,
|
||||
) {
|
||||
return ServerDeletedChecksumEntityData(
|
||||
checksum: data.checksum.present ? data.checksum.value : this.checksum,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('ServerDeletedChecksumEntityData(')
|
||||
..write('checksum: $checksum')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => checksum.hashCode;
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.ServerDeletedChecksumEntityData &&
|
||||
other.checksum == this.checksum);
|
||||
}
|
||||
|
||||
class ServerDeletedChecksumEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.ServerDeletedChecksumEntityData> {
|
||||
final i0.Value<String> checksum;
|
||||
const ServerDeletedChecksumEntityCompanion({
|
||||
this.checksum = const i0.Value.absent(),
|
||||
});
|
||||
ServerDeletedChecksumEntityCompanion.insert({required String checksum})
|
||||
: checksum = i0.Value(checksum);
|
||||
static i0.Insertable<i1.ServerDeletedChecksumEntityData> custom({
|
||||
i0.Expression<String>? checksum,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({if (checksum != null) 'checksum': checksum});
|
||||
}
|
||||
|
||||
i1.ServerDeletedChecksumEntityCompanion copyWith({
|
||||
i0.Value<String>? checksum,
|
||||
}) {
|
||||
return i1.ServerDeletedChecksumEntityCompanion(
|
||||
checksum: checksum ?? this.checksum,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (checksum.present) {
|
||||
map['checksum'] = i0.Variable<String>(checksum.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('ServerDeletedChecksumEntityCompanion(')
|
||||
..write('checksum: $checksum')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
22
mobile/lib/infrastructure/entities/trash_sync.entity.dart
Normal file
22
mobile/lib/infrastructure/entities/trash_sync.entity.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)')
|
||||
class TrashSyncEntity extends Table with DriftDefaultsMixin {
|
||||
const TrashSyncEntity();
|
||||
|
||||
TextColumn get assetId => text()();
|
||||
|
||||
TextColumn get checksum => text()();
|
||||
|
||||
IntColumn get status => intEnum<TrashSyncStatus>().withDefault(Constant(TrashSyncStatus.pending.index))();
|
||||
|
||||
DateTimeColumn get assetUpdatedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {assetId};
|
||||
|
||||
@override
|
||||
String get tableName => "trash_sync";
|
||||
}
|
||||
524
mobile/lib/infrastructure/entities/trash_sync.entity.drift.dart
generated
Normal file
524
mobile/lib/infrastructure/entities/trash_sync.entity.drift.dart
generated
Normal file
@@ -0,0 +1,524 @@
|
||||
// dart format width=80
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:drift/drift.dart' as i0;
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
|
||||
as i1;
|
||||
import 'package:immich_mobile/constants/enums.dart' as i2;
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart'
|
||||
as i3;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
|
||||
|
||||
typedef $$TrashSyncEntityTableCreateCompanionBuilder =
|
||||
i1.TrashSyncEntityCompanion Function({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
i0.Value<i2.TrashSyncStatus> status,
|
||||
i0.Value<DateTime?> assetUpdatedAt,
|
||||
});
|
||||
typedef $$TrashSyncEntityTableUpdateCompanionBuilder =
|
||||
i1.TrashSyncEntityCompanion Function({
|
||||
i0.Value<String> assetId,
|
||||
i0.Value<String> checksum,
|
||||
i0.Value<i2.TrashSyncStatus> status,
|
||||
i0.Value<DateTime?> assetUpdatedAt,
|
||||
});
|
||||
|
||||
class $$TrashSyncEntityTableFilterComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
|
||||
$$TrashSyncEntityTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnFilters<String> get assetId => $composableBuilder(
|
||||
column: $table.assetId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnWithTypeConverterFilters<i2.TrashSyncStatus, i2.TrashSyncStatus, int>
|
||||
get status => $composableBuilder(
|
||||
column: $table.status,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get assetUpdatedAt => $composableBuilder(
|
||||
column: $table.assetUpdatedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashSyncEntityTableOrderingComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
|
||||
$$TrashSyncEntityTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.ColumnOrderings<String> get assetId => $composableBuilder(
|
||||
column: $table.assetId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get checksum => $composableBuilder(
|
||||
column: $table.checksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<int> get status => $composableBuilder(
|
||||
column: $table.status,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get assetUpdatedAt => $composableBuilder(
|
||||
column: $table.assetUpdatedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashSyncEntityTableAnnotationComposer
|
||||
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
|
||||
$$TrashSyncEntityTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
i0.GeneratedColumn<String> get assetId =>
|
||||
$composableBuilder(column: $table.assetId, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<String> get checksum =>
|
||||
$composableBuilder(column: $table.checksum, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i2.TrashSyncStatus, int> get status =>
|
||||
$composableBuilder(column: $table.status, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get assetUpdatedAt => $composableBuilder(
|
||||
column: $table.assetUpdatedAt,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashSyncEntityTableTableManager
|
||||
extends
|
||||
i0.RootTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData,
|
||||
i1.$$TrashSyncEntityTableFilterComposer,
|
||||
i1.$$TrashSyncEntityTableOrderingComposer,
|
||||
i1.$$TrashSyncEntityTableAnnotationComposer,
|
||||
$$TrashSyncEntityTableCreateCompanionBuilder,
|
||||
$$TrashSyncEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.TrashSyncEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData
|
||||
>,
|
||||
),
|
||||
i1.TrashSyncEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
> {
|
||||
$$TrashSyncEntityTableTableManager(
|
||||
i0.GeneratedDatabase db,
|
||||
i1.$TrashSyncEntityTable table,
|
||||
) : super(
|
||||
i0.TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
i1.$$TrashSyncEntityTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
i1.$$TrashSyncEntityTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () => i1
|
||||
.$$TrashSyncEntityTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
i0.Value<String> assetId = const i0.Value.absent(),
|
||||
i0.Value<String> checksum = const i0.Value.absent(),
|
||||
i0.Value<i2.TrashSyncStatus> status = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> assetUpdatedAt = const i0.Value.absent(),
|
||||
}) => i1.TrashSyncEntityCompanion(
|
||||
assetId: assetId,
|
||||
checksum: checksum,
|
||||
status: status,
|
||||
assetUpdatedAt: assetUpdatedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
i0.Value<i2.TrashSyncStatus> status = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> assetUpdatedAt = const i0.Value.absent(),
|
||||
}) => i1.TrashSyncEntityCompanion.insert(
|
||||
assetId: assetId,
|
||||
checksum: checksum,
|
||||
status: status,
|
||||
assetUpdatedAt: assetUpdatedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$TrashSyncEntityTableProcessedTableManager =
|
||||
i0.ProcessedTableManager<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData,
|
||||
i1.$$TrashSyncEntityTableFilterComposer,
|
||||
i1.$$TrashSyncEntityTableOrderingComposer,
|
||||
i1.$$TrashSyncEntityTableAnnotationComposer,
|
||||
$$TrashSyncEntityTableCreateCompanionBuilder,
|
||||
$$TrashSyncEntityTableUpdateCompanionBuilder,
|
||||
(
|
||||
i1.TrashSyncEntityData,
|
||||
i0.BaseReferences<
|
||||
i0.GeneratedDatabase,
|
||||
i1.$TrashSyncEntityTable,
|
||||
i1.TrashSyncEntityData
|
||||
>,
|
||||
),
|
||||
i1.TrashSyncEntityData,
|
||||
i0.PrefetchHooks Function()
|
||||
>;
|
||||
i0.Index get idxTrashSyncChecksum => i0.Index(
|
||||
'idx_trash_sync_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)',
|
||||
);
|
||||
|
||||
class $TrashSyncEntityTable extends i3.TrashSyncEntity
|
||||
with i0.TableInfo<$TrashSyncEntityTable, i1.TrashSyncEntityData> {
|
||||
@override
|
||||
final i0.GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$TrashSyncEntityTable(this.attachedDatabase, [this._alias]);
|
||||
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
|
||||
'assetId',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
|
||||
'asset_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
|
||||
'checksum',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
|
||||
'checksum',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumnWithTypeConverter<i2.TrashSyncStatus, int>
|
||||
status =
|
||||
i0.GeneratedColumn<int>(
|
||||
'status',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: i4.Constant(i2.TrashSyncStatus.pending.index),
|
||||
).withConverter<i2.TrashSyncStatus>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus,
|
||||
);
|
||||
static const i0.VerificationMeta _assetUpdatedAtMeta =
|
||||
const i0.VerificationMeta('assetUpdatedAt');
|
||||
@override
|
||||
late final i0.GeneratedColumn<DateTime> assetUpdatedAt =
|
||||
i0.GeneratedColumn<DateTime>(
|
||||
'asset_updated_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
assetId,
|
||||
checksum,
|
||||
status,
|
||||
assetUpdatedAt,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'trash_sync';
|
||||
@override
|
||||
i0.VerificationContext validateIntegrity(
|
||||
i0.Insertable<i1.TrashSyncEntityData> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = i0.VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('asset_id')) {
|
||||
context.handle(
|
||||
_assetIdMeta,
|
||||
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_assetIdMeta);
|
||||
}
|
||||
if (data.containsKey('checksum')) {
|
||||
context.handle(
|
||||
_checksumMeta,
|
||||
checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_checksumMeta);
|
||||
}
|
||||
if (data.containsKey('asset_updated_at')) {
|
||||
context.handle(
|
||||
_assetUpdatedAtMeta,
|
||||
assetUpdatedAt.isAcceptableOrUnknown(
|
||||
data['asset_updated_at']!,
|
||||
_assetUpdatedAtMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<i0.GeneratedColumn> get $primaryKey => {assetId};
|
||||
@override
|
||||
i1.TrashSyncEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return i1.TrashSyncEntityData(
|
||||
assetId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}asset_id'],
|
||||
)!,
|
||||
checksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}checksum'],
|
||||
)!,
|
||||
status: i1.$TrashSyncEntityTable.$converterstatus.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}status'],
|
||||
)!,
|
||||
),
|
||||
assetUpdatedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}asset_updated_at'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$TrashSyncEntityTable createAlias(String alias) {
|
||||
return $TrashSyncEntityTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
static i0.JsonTypeConverter2<i2.TrashSyncStatus, int, int> $converterstatus =
|
||||
const i0.EnumIndexConverter<i2.TrashSyncStatus>(
|
||||
i2.TrashSyncStatus.values,
|
||||
);
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
bool get isStrict => true;
|
||||
}
|
||||
|
||||
class TrashSyncEntityData extends i0.DataClass
|
||||
implements i0.Insertable<i1.TrashSyncEntityData> {
|
||||
final String assetId;
|
||||
final String checksum;
|
||||
final i2.TrashSyncStatus status;
|
||||
final DateTime? assetUpdatedAt;
|
||||
const TrashSyncEntityData({
|
||||
required this.assetId,
|
||||
required this.checksum,
|
||||
required this.status,
|
||||
this.assetUpdatedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
map['asset_id'] = i0.Variable<String>(assetId);
|
||||
map['checksum'] = i0.Variable<String>(checksum);
|
||||
{
|
||||
map['status'] = i0.Variable<int>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus.toSql(status),
|
||||
);
|
||||
}
|
||||
if (!nullToAbsent || assetUpdatedAt != null) {
|
||||
map['asset_updated_at'] = i0.Variable<DateTime>(assetUpdatedAt);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
factory TrashSyncEntityData.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
i0.ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return TrashSyncEntityData(
|
||||
assetId: serializer.fromJson<String>(json['assetId']),
|
||||
checksum: serializer.fromJson<String>(json['checksum']),
|
||||
status: i1.$TrashSyncEntityTable.$converterstatus.fromJson(
|
||||
serializer.fromJson<int>(json['status']),
|
||||
),
|
||||
assetUpdatedAt: serializer.fromJson<DateTime?>(json['assetUpdatedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'assetId': serializer.toJson<String>(assetId),
|
||||
'checksum': serializer.toJson<String>(checksum),
|
||||
'status': serializer.toJson<int>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus.toJson(status),
|
||||
),
|
||||
'assetUpdatedAt': serializer.toJson<DateTime?>(assetUpdatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
i1.TrashSyncEntityData copyWith({
|
||||
String? assetId,
|
||||
String? checksum,
|
||||
i2.TrashSyncStatus? status,
|
||||
i0.Value<DateTime?> assetUpdatedAt = const i0.Value.absent(),
|
||||
}) => i1.TrashSyncEntityData(
|
||||
assetId: assetId ?? this.assetId,
|
||||
checksum: checksum ?? this.checksum,
|
||||
status: status ?? this.status,
|
||||
assetUpdatedAt: assetUpdatedAt.present
|
||||
? assetUpdatedAt.value
|
||||
: this.assetUpdatedAt,
|
||||
);
|
||||
TrashSyncEntityData copyWithCompanion(i1.TrashSyncEntityCompanion data) {
|
||||
return TrashSyncEntityData(
|
||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
||||
checksum: data.checksum.present ? data.checksum.value : this.checksum,
|
||||
status: data.status.present ? data.status.value : this.status,
|
||||
assetUpdatedAt: data.assetUpdatedAt.present
|
||||
? data.assetUpdatedAt.value
|
||||
: this.assetUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('TrashSyncEntityData(')
|
||||
..write('assetId: $assetId, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('status: $status, ')
|
||||
..write('assetUpdatedAt: $assetUpdatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(assetId, checksum, status, assetUpdatedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is i1.TrashSyncEntityData &&
|
||||
other.assetId == this.assetId &&
|
||||
other.checksum == this.checksum &&
|
||||
other.status == this.status &&
|
||||
other.assetUpdatedAt == this.assetUpdatedAt);
|
||||
}
|
||||
|
||||
class TrashSyncEntityCompanion
|
||||
extends i0.UpdateCompanion<i1.TrashSyncEntityData> {
|
||||
final i0.Value<String> assetId;
|
||||
final i0.Value<String> checksum;
|
||||
final i0.Value<i2.TrashSyncStatus> status;
|
||||
final i0.Value<DateTime?> assetUpdatedAt;
|
||||
const TrashSyncEntityCompanion({
|
||||
this.assetId = const i0.Value.absent(),
|
||||
this.checksum = const i0.Value.absent(),
|
||||
this.status = const i0.Value.absent(),
|
||||
this.assetUpdatedAt = const i0.Value.absent(),
|
||||
});
|
||||
TrashSyncEntityCompanion.insert({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
this.status = const i0.Value.absent(),
|
||||
this.assetUpdatedAt = const i0.Value.absent(),
|
||||
}) : assetId = i0.Value(assetId),
|
||||
checksum = i0.Value(checksum);
|
||||
static i0.Insertable<i1.TrashSyncEntityData> custom({
|
||||
i0.Expression<String>? assetId,
|
||||
i0.Expression<String>? checksum,
|
||||
i0.Expression<int>? status,
|
||||
i0.Expression<DateTime>? assetUpdatedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (assetId != null) 'asset_id': assetId,
|
||||
if (checksum != null) 'checksum': checksum,
|
||||
if (status != null) 'status': status,
|
||||
if (assetUpdatedAt != null) 'asset_updated_at': assetUpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
i1.TrashSyncEntityCompanion copyWith({
|
||||
i0.Value<String>? assetId,
|
||||
i0.Value<String>? checksum,
|
||||
i0.Value<i2.TrashSyncStatus>? status,
|
||||
i0.Value<DateTime?>? assetUpdatedAt,
|
||||
}) {
|
||||
return i1.TrashSyncEntityCompanion(
|
||||
assetId: assetId ?? this.assetId,
|
||||
checksum: checksum ?? this.checksum,
|
||||
status: status ?? this.status,
|
||||
assetUpdatedAt: assetUpdatedAt ?? this.assetUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, i0.Expression>{};
|
||||
if (assetId.present) {
|
||||
map['asset_id'] = i0.Variable<String>(assetId.value);
|
||||
}
|
||||
if (checksum.present) {
|
||||
map['checksum'] = i0.Variable<String>(checksum.value);
|
||||
}
|
||||
if (status.present) {
|
||||
map['status'] = i0.Variable<int>(
|
||||
i1.$TrashSyncEntityTable.$converterstatus.toSql(status.value),
|
||||
);
|
||||
}
|
||||
if (assetUpdatedAt.present) {
|
||||
map['asset_updated_at'] = i0.Variable<DateTime>(assetUpdatedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('TrashSyncEntityCompanion(')
|
||||
..write('assetId: $assetId, ')
|
||||
..write('checksum: $checksum, ')
|
||||
..write('status: $status, ')
|
||||
..write('assetUpdatedAt: $assetUpdatedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
enum TrashOrigin {
|
||||
// do not change this order!
|
||||
localSync,
|
||||
remoteSync,
|
||||
localUser,
|
||||
}
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
|
||||
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const TrashedLocalAssetEntity();
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get albumId => text()();
|
||||
|
||||
TextColumn get checksum => text().nullable()();
|
||||
|
||||
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
|
||||
|
||||
IntColumn get orientation => integer().withDefault(const Constant(0))();
|
||||
|
||||
IntColumn get source => intEnum<TrashOrigin>()();
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id, albumId};
|
||||
}
|
||||
|
||||
extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData {
|
||||
LocalAsset toLocalAsset() => LocalAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
checksum: checksum,
|
||||
type: type,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
durationMs: durationMs,
|
||||
isFavorite: isFavorite,
|
||||
height: height,
|
||||
width: width,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,12 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?3
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM trash_sync ts WHERE ts.asset_id = lae.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM server_deleted_checksum sd WHERE sd.checksum = lae.checksum
|
||||
);
|
||||
''';
|
||||
|
||||
@@ -69,7 +75,14 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
Variable.withInt(BackupSelection.selected.index),
|
||||
Variable.withInt(BackupSelection.excluded.index),
|
||||
],
|
||||
readsFrom: {_db.localAlbumAssetEntity, _db.localAlbumEntity, _db.localAssetEntity, _db.remoteAssetEntity},
|
||||
readsFrom: {
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumEntity,
|
||||
_db.localAssetEntity,
|
||||
_db.remoteAssetEntity,
|
||||
_db.trashSyncEntity,
|
||||
_db.serverDeletedChecksumEntity,
|
||||
},
|
||||
)
|
||||
.getSingle();
|
||||
|
||||
@@ -104,6 +117,16 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
||||
),
|
||||
) &
|
||||
notExistsQuery(
|
||||
_db.trashSyncEntity.selectOnly()
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..where(_db.trashSyncEntity.assetId.equalsExp(lae.id)),
|
||||
) &
|
||||
notExistsQuery(
|
||||
_db.serverDeletedChecksumEntity.selectOnly()
|
||||
..addColumns([_db.serverDeletedChecksumEntity.checksum])
|
||||
..where(_db.serverDeletedChecksumEntity.checksum.equalsExp(lae.checksum)),
|
||||
) &
|
||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||
)
|
||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||
|
||||
@@ -25,11 +25,11 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
|
||||
@@ -63,7 +63,8 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
PersonEntity,
|
||||
AssetFaceEntity,
|
||||
StoreEntity,
|
||||
TrashedLocalAssetEntity,
|
||||
TrashSyncEntity,
|
||||
ServerDeletedChecksumEntity,
|
||||
AssetEditEntity,
|
||||
SettingsEntity,
|
||||
AssetOcrEntity,
|
||||
@@ -120,7 +121,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 31;
|
||||
int get schemaVersion => 32;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -221,7 +222,7 @@ class Drift extends $Drift {
|
||||
await m.alterTable(
|
||||
TableMigration(
|
||||
v15.trashedLocalAssetEntity,
|
||||
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
|
||||
columnTransformer: {v15.trashedLocalAssetEntity.source: const Constant(0)}, // TrashOrigin.localSync
|
||||
newColumns: [v15.trashedLocalAssetEntity.source],
|
||||
),
|
||||
);
|
||||
@@ -280,11 +281,7 @@ class Drift extends $Drift {
|
||||
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000),
|
||||
),
|
||||
);
|
||||
await trashedLocalAssetEntity.update().write(
|
||||
TrashedLocalAssetEntityCompanion.custom(
|
||||
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
|
||||
),
|
||||
);
|
||||
await customStatement('UPDATE trashed_local_asset_entity SET duration_ms = duration_ms * 1000');
|
||||
},
|
||||
from23To24: (m, v24) async {
|
||||
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
|
||||
@@ -318,6 +315,13 @@ class Drift extends $Drift {
|
||||
from30To31: (m, v31) async {
|
||||
await m.createIndex(v31.idxRemoteAssetUploaded);
|
||||
},
|
||||
from31To32: (m, v32) async {
|
||||
await m.createTable(v32.trashSync);
|
||||
await m.create(v32.idxTrashSyncChecksum);
|
||||
await m.createTable(v32.serverDeletedChecksum);
|
||||
await m.create(v32.idxRemoteAssetSoftDeletedChecksum);
|
||||
await m.deleteTable('trashed_local_asset_entity');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -349,6 +353,8 @@ class DriftDatabaseRepository {
|
||||
const DriftDatabaseRepository(this._db);
|
||||
|
||||
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
|
||||
|
||||
BaseSelectStatement currentUserIdQuery() => _db.selectOnly(_db.authUserEntity)..addColumns([_db.authUserEntity.id]);
|
||||
}
|
||||
|
||||
// ignore: invalid_use_of_internal_member
|
||||
|
||||
@@ -39,17 +39,19 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
|
||||
as i18;
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
||||
as i19;
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
|
||||
as i20;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart'
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
||||
as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
|
||||
as i23;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
|
||||
as i24;
|
||||
import 'package:drift/internal/modular.dart' as i25;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i25;
|
||||
import 'package:drift/internal/modular.dart' as i26;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -89,19 +91,21 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i18.$AssetFaceEntityTable assetFaceEntity = i18
|
||||
.$AssetFaceEntityTable(this);
|
||||
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
|
||||
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
|
||||
.$TrashedLocalAssetEntityTable(this);
|
||||
late final i21.$AssetEditEntityTable assetEditEntity = i21
|
||||
late final i20.$TrashSyncEntityTable trashSyncEntity = i20
|
||||
.$TrashSyncEntityTable(this);
|
||||
late final i21.$ServerDeletedChecksumEntityTable serverDeletedChecksumEntity =
|
||||
i21.$ServerDeletedChecksumEntityTable(this);
|
||||
late final i22.$AssetEditEntityTable assetEditEntity = i22
|
||||
.$AssetEditEntityTable(this);
|
||||
late final i22.$SettingsEntityTable settingsEntity = i22.$SettingsEntityTable(
|
||||
late final i23.$SettingsEntityTable settingsEntity = i23.$SettingsEntityTable(
|
||||
this,
|
||||
);
|
||||
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
|
||||
late final i24.$AssetOcrEntityTable assetOcrEntity = i24.$AssetOcrEntityTable(
|
||||
this,
|
||||
);
|
||||
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
|
||||
i25.MergedAssetDrift get mergedAssetDrift => i26.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
|
||||
).accessor<i25.MergedAssetDrift>(i25.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -122,6 +126,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
i2.idxRemoteAssetChecksum,
|
||||
i2.idxRemoteAssetSoftDeletedChecksum,
|
||||
i2.idxRemoteAssetStackId,
|
||||
i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
i2.idxRemoteAssetUploaded,
|
||||
@@ -137,7 +142,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
trashSyncEntity,
|
||||
serverDeletedChecksumEntity,
|
||||
assetEditEntity,
|
||||
settingsEntity,
|
||||
assetOcrEntity,
|
||||
@@ -150,10 +156,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i18.idxAssetFacePersonId,
|
||||
i18.idxAssetFaceAssetId,
|
||||
i18.idxAssetFaceVisiblePerson,
|
||||
i20.idxTrashedLocalAssetChecksum,
|
||||
i20.idxTrashedLocalAssetAlbum,
|
||||
i21.idxAssetEditAssetId,
|
||||
i23.idxAssetOcrAssetId,
|
||||
i20.idxTrashSyncChecksum,
|
||||
i22.idxAssetEditAssetId,
|
||||
i24.idxAssetOcrAssetId,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules
|
||||
@@ -404,15 +409,18 @@ class $DriftManager {
|
||||
i18.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
|
||||
i19.$$StoreEntityTableTableManager get storeEntity =>
|
||||
i19.$$StoreEntityTableTableManager(_db, _db.storeEntity);
|
||||
i20.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
|
||||
i20.$$TrashedLocalAssetEntityTableTableManager(
|
||||
i20.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
|
||||
i20.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
|
||||
i21.$$ServerDeletedChecksumEntityTableTableManager
|
||||
get serverDeletedChecksumEntity =>
|
||||
i21.$$ServerDeletedChecksumEntityTableTableManager(
|
||||
_db,
|
||||
_db.trashedLocalAssetEntity,
|
||||
_db.serverDeletedChecksumEntity,
|
||||
);
|
||||
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i22.$$SettingsEntityTableTableManager get settingsEntity =>
|
||||
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
|
||||
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
i22.$$AssetEditEntityTableTableManager get assetEditEntity =>
|
||||
i22.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i23.$$SettingsEntityTableTableManager get settingsEntity =>
|
||||
i23.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
|
||||
i24.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i24.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
}
|
||||
|
||||
@@ -16506,6 +16506,632 @@ final class Schema31 extends i0.VersionedSchema {
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema32 extends i0.VersionedSchema {
|
||||
Schema32({required super.database}) : super(version: 32);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetCreatedAt,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetSoftDeletedChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
idxRemoteAssetUploaded,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashSync,
|
||||
serverDeletedChecksum,
|
||||
assetEditEntity,
|
||||
settings,
|
||||
assetOcrEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashSyncChecksum,
|
||||
idxAssetEditAssetId,
|
||||
idxAssetOcrAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape36 localAssetEntity = Shape36(
|
||||
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,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCreatedAt = i1.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
final i1.Index 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 idxRemoteAssetSoftDeletedChecksum = i1.Index(
|
||||
'idx_remote_asset_soft_deleted_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_soft_deleted_checksum ON remote_asset_entity (checksum) WHERE deleted_at IS NOT NULL',
|
||||
);
|
||||
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)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetUploaded = i1.Index(
|
||||
'idx_remote_asset_uploaded',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
|
||||
);
|
||||
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 Shape52 trashSync = Shape52(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trash_sync',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [_column_225, _column_119, _column_226, _column_227],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape53 serverDeletedChecksum = Shape53(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'server_deleted_checksum',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(checksum)'],
|
||||
columns: [_column_119],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 settings = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'settings',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_224, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 assetOcrEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
_column_217,
|
||||
_column_218,
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_221,
|
||||
_column_222,
|
||||
_column_223,
|
||||
_column_201,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
final i1.Index idxTrashSyncChecksum = i1.Index(
|
||||
'idx_trash_sync_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync (checksum)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetOcrAssetId = i1.Index(
|
||||
'idx_asset_ocr_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape52 extends i0.VersionedTable {
|
||||
Shape52({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get assetId =>
|
||||
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get status =>
|
||||
columnsByName['status']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get assetUpdatedAt =>
|
||||
columnsByName['asset_updated_at']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'asset_id',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_226(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'status',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL DEFAULT 0',
|
||||
defaultValue: const i1.CustomExpression('0'),
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_227(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'asset_updated_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
|
||||
class Shape53 extends i0.VersionedTable {
|
||||
Shape53({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -16537,6 +17163,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -16690,6 +17317,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from30To31(migrator, schema);
|
||||
return 31;
|
||||
case 31:
|
||||
final schema = Schema32(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from31To32(migrator, schema);
|
||||
return 32;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -16727,6 +17359,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
||||
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -16759,5 +17392,6 @@ i1.OnUpgrade stepByStep({
|
||||
from28To29: from28To29,
|
||||
from29To30: from29To30,
|
||||
from30To31: from30To31,
|
||||
from31To32: from31To32,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SettingsRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
@@ -31,6 +32,9 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void reset() => _instance = null;
|
||||
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
|
||||
|
||||
Future<void> clear(Iterable<SettingsKey> keys) async {
|
||||
|
||||
@@ -71,6 +71,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetEditEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
await _db.trashSyncEntity.deleteAll();
|
||||
await _db.serverDeletedChecksumEntity.deleteAll();
|
||||
});
|
||||
} finally {
|
||||
// re-enable FK even if the transaction throws, otherwise the connection
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class DriftTrashSyncRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const DriftTrashSyncRepository(this._db) : super(_db);
|
||||
|
||||
// Prunes asset markers for assets that are now live on the server
|
||||
Future<void> pruneStaleMarkers() async {
|
||||
final liveChecksums = _db.selectOnly(_db.remoteAssetEntity)
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()));
|
||||
|
||||
await _db.transaction(() async {
|
||||
await (_db.delete(_db.serverDeletedChecksumEntity)..where((t) => t.checksum.isInQuery(liveChecksums))).go();
|
||||
await (_db.delete(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.checksum.isInQuery(liveChecksums) & t.status.equalsValue(.pending))).go();
|
||||
});
|
||||
}
|
||||
|
||||
// Prunes dismissed assets marker for assets that are not on the server anymore
|
||||
Future<void> pruneDismissedMarkers() async {
|
||||
final softDeletedChecksums = _db.selectOnly(_db.remoteAssetEntity)
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
..where(
|
||||
_db.remoteAssetEntity.deletedAt.isNotNull() & _db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
);
|
||||
|
||||
final serverDeletedChecksums = _db.selectOnly(_db.serverDeletedChecksumEntity)
|
||||
..addColumns([_db.serverDeletedChecksumEntity.checksum]);
|
||||
|
||||
await (_db.delete(_db.trashSyncEntity)..where(
|
||||
(t) =>
|
||||
t.status.equalsValue(.dismissed) &
|
||||
t.checksum.isNotInQuery(softDeletedChecksums) &
|
||||
t.checksum.isNotInQuery(serverDeletedChecksums),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
// Prunes pending markers for assets that has been modified locally
|
||||
Future<void> prunePendingMarkers() async {
|
||||
final staleIds = _db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.localAssetEntity.id.equalsExp(_db.trashSyncEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.trashSyncEntity.status.equalsValue(.pending) &
|
||||
_db.localAssetEntity.checksum.isNotNull() &
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum).not(),
|
||||
);
|
||||
await (_db.delete(_db.trashSyncEntity)..where((t) => t.assetId.isInQuery(staleIds))).go();
|
||||
}
|
||||
|
||||
Future<void> recordHardDeletedChecksums(Iterable<String> remoteIds) async {
|
||||
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
|
||||
final checksums =
|
||||
await (_db.selectOnly(_db.remoteAssetEntity, distinct: true)
|
||||
..addColumns([_db.remoteAssetEntity.checksum])
|
||||
..where(
|
||||
_db.remoteAssetEntity.id.isIn(slice) & _db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
))
|
||||
.map((r) => r.read(_db.remoteAssetEntity.checksum)!)
|
||||
.get();
|
||||
if (checksums.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await _db.batch((batch) {
|
||||
for (final checksum in checksums) {
|
||||
batch.insert(
|
||||
_db.serverDeletedChecksumEntity,
|
||||
ServerDeletedChecksumEntityCompanion.insert(checksum: checksum),
|
||||
onConflict: DoNothing(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> recordSoftDeleteAssets() => _recordAssets(
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum) &
|
||||
_db.remoteAssetEntity.deletedAt.isNotNull() &
|
||||
_db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
useColumns: false,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> recordHardDeletedAssets() => _recordAssets(
|
||||
innerJoin(
|
||||
_db.serverDeletedChecksumEntity,
|
||||
_db.serverDeletedChecksumEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _recordAssets(Join contentJoin) async {
|
||||
final excludedAssetIds = _db.selectOnly(_db.localAlbumAssetEntity)
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumEntity.id.equalsExp(_db.localAlbumAssetEntity.albumId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(.excluded));
|
||||
|
||||
final selectedAssetsQuery = _db.selectOnly(_db.localAlbumAssetEntity)
|
||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||
..where(
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id) &
|
||||
_db.localAlbumAssetEntity.albumId.isInQuery(
|
||||
_db.selectOnly(_db.localAlbumEntity)
|
||||
..addColumns([_db.localAlbumEntity.id])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(.selected)),
|
||||
),
|
||||
);
|
||||
|
||||
final dismissedAssetsQuery = _db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..where(
|
||||
_db.trashSyncEntity.checksum.equalsExp(_db.localAssetEntity.checksum) &
|
||||
_db.trashSyncEntity.status.equalsValue(.dismissed),
|
||||
);
|
||||
|
||||
final source = _db.selectOnly(_db.localAssetEntity)
|
||||
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum, _db.localAssetEntity.updatedAt])
|
||||
..join([contentJoin])
|
||||
..where(
|
||||
_db.localAssetEntity.checksum.isNotNull() &
|
||||
existsQuery(selectedAssetsQuery) &
|
||||
_db.localAssetEntity.id.isNotInQuery(excludedAssetIds) &
|
||||
notExistsQuery(dismissedAssetsQuery),
|
||||
);
|
||||
|
||||
await _db
|
||||
.into(_db.trashSyncEntity)
|
||||
.insertFromSelect(
|
||||
source,
|
||||
columns: {
|
||||
_db.trashSyncEntity.assetId: _db.localAssetEntity.id,
|
||||
_db.trashSyncEntity.checksum: _db.localAssetEntity.checksum,
|
||||
_db.trashSyncEntity.assetUpdatedAt: _db.localAssetEntity.updatedAt,
|
||||
},
|
||||
mode: .insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markTrashed(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _db.transaction(() async {
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.update(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.assetId.isIn(slice))).write(const TrashSyncEntityCompanion(status: .new(.trashed)));
|
||||
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> markRestored(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.update(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.assetId.isIn(slice))).write(const TrashSyncEntityCompanion(status: .new(.restored)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restoreChecksums() async {
|
||||
final restored = await (_db.select(_db.trashSyncEntity)..where((t) => t.status.equalsValue(.restored))).get();
|
||||
if (restored.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.batch((batch) {
|
||||
for (final row in restored) {
|
||||
final assetUpdatedAt = row.assetUpdatedAt;
|
||||
if (assetUpdatedAt == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.update(
|
||||
_db.localAssetEntity,
|
||||
LocalAssetEntityCompanion(checksum: .new(row.checksum)),
|
||||
where: (t) => t.id.equals(row.assetId) & t.checksum.isNull() & t.updatedAt.equals(assetUpdatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
batch.deleteWhere(_db.trashSyncEntity, (t) => t.status.equalsValue(.restored));
|
||||
});
|
||||
}
|
||||
|
||||
// Mark assets that were previously marked as trashed but are now live on the device as dismissed
|
||||
Future<void> reconcileTrashed(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
JoinedSelectStatement localAssetQuery($TrashSyncEntityTable trash) => _db.selectOnly(_db.localAssetEntity)
|
||||
..addColumns([_db.localAssetEntity.id])
|
||||
..where(_db.localAssetEntity.id.equalsExp(trash.assetId));
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.update(_db.trashSyncEntity)..where((t) => t.assetId.isIn(slice) & existsQuery(localAssetQuery(t))))
|
||||
.write(const TrashSyncEntityCompanion(status: .new(.dismissed)));
|
||||
|
||||
await (_db.delete(
|
||||
_db.trashSyncEntity,
|
||||
)..where((t) => t.assetId.isIn(slice) & notExistsQuery(localAssetQuery(t)))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteMarkers(Iterable<String> assetIds) async {
|
||||
final set = assetIds.toSet();
|
||||
if (set.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final slice in set.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashSyncEntity)..where((t) => t.assetId.isIn(slice))).go();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> getPendingAssetIds() =>
|
||||
_trashSyncAssetIdsWhere(_db.trashSyncEntity.status.equalsValue(.pending));
|
||||
|
||||
Future<List<String>> getTrashedAssetIds() =>
|
||||
_trashSyncAssetIdsWhere(_db.trashSyncEntity.status.equalsValue(.trashed));
|
||||
|
||||
Future<List<String>> _trashSyncAssetIdsWhere(Expression<bool> filter) {
|
||||
return (_db.selectOnly(_db.trashSyncEntity)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..where(filter))
|
||||
.map((row) => row.read(_db.trashSyncEntity.assetId)!)
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<List<String>> getRestorableAssetIds() {
|
||||
return (_db.selectOnly(_db.trashSyncEntity, distinct: true)
|
||||
..addColumns([_db.trashSyncEntity.assetId])
|
||||
..join([
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
_db.trashSyncEntity.status.equalsValue(.trashed) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.isInQuery(currentUserIdQuery()),
|
||||
))
|
||||
.map((row) => row.read(_db.trashSyncEntity.assetId)!)
|
||||
.get();
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
typedef TrashedAsset = ({String albumId, LocalAsset asset});
|
||||
|
||||
class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
|
||||
const DriftTrashedLocalAssetRepository(this._db) : super(_db);
|
||||
|
||||
Future<void> updateHashes(Map<String, String> hashes) {
|
||||
if (hashes.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
return _db.batch((batch) async {
|
||||
for (final entry in hashes.entries) {
|
||||
batch.update(
|
||||
_db.trashedLocalAssetEntity,
|
||||
TrashedLocalAssetEntityCompanion(checksum: Value(entry.value)),
|
||||
where: (e) => e.id.equals(entry.key),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<LocalAsset>> getAssetsToHash(Iterable<String> albumIds) {
|
||||
final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull());
|
||||
return query.map((row) => row.toLocalAsset()).get();
|
||||
}
|
||||
|
||||
Future<Iterable<LocalAsset>> getToRestore() async {
|
||||
final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity)
|
||||
..addColumns([_db.localAlbumEntity.id])
|
||||
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected)));
|
||||
|
||||
final rows =
|
||||
await (_db.select(_db.trashedLocalAssetEntity).join([
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum),
|
||||
),
|
||||
])..where(
|
||||
_db.trashedLocalAssetEntity.source.equalsValue(TrashOrigin.remoteSync) &
|
||||
_db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset());
|
||||
}
|
||||
|
||||
/// Applies resulted snapshot of trashed assets:
|
||||
/// - upserts incoming rows
|
||||
/// - deletes rows that are not present in the snapshot
|
||||
Future<void> processTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async {
|
||||
if (trashedAssets.isEmpty) {
|
||||
await _db.delete(_db.trashedLocalAssetEntity).go();
|
||||
return;
|
||||
}
|
||||
final assetIds = trashedAssets.map((e) => e.asset.id).toSet();
|
||||
Map<String, String> localChecksumById = await _getCachedChecksums(assetIds);
|
||||
|
||||
return _db.transaction(() async {
|
||||
await _db.batch((batch) {
|
||||
for (final item in trashedAssets) {
|
||||
final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum;
|
||||
final companion = TrashedLocalAssetEntityCompanion.insert(
|
||||
id: item.asset.id,
|
||||
albumId: item.albumId,
|
||||
checksum: Value(effectiveChecksum),
|
||||
name: item.asset.name,
|
||||
type: item.asset.type,
|
||||
createdAt: Value(item.asset.createdAt),
|
||||
updatedAt: Value(item.asset.updatedAt),
|
||||
width: Value(item.asset.width),
|
||||
height: Value(item.asset.height),
|
||||
durationMs: Value(item.asset.durationMs),
|
||||
isFavorite: Value(item.asset.isFavorite),
|
||||
orientation: Value(item.asset.orientation),
|
||||
playbackStyle: Value(item.asset.playbackStyle),
|
||||
source: TrashOrigin.localSync,
|
||||
);
|
||||
|
||||
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
|
||||
_db.trashedLocalAssetEntity,
|
||||
companion,
|
||||
onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (assetIds.length <= kDriftMaxChunk) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(assetIds))).go();
|
||||
} else {
|
||||
final existingIds = await (_db.selectOnly(
|
||||
_db.trashedLocalAssetEntity,
|
||||
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
|
||||
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Stream<int> watchCount() {
|
||||
return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()]))
|
||||
.watchSingle()
|
||||
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||
}
|
||||
|
||||
Stream<int> watchHashedCount() {
|
||||
return (_db.selectOnly(_db.trashedLocalAssetEntity)
|
||||
..addColumns([_db.trashedLocalAssetEntity.id.count()])
|
||||
..where(_db.trashedLocalAssetEntity.checksum.isNotNull()))
|
||||
.watchSingle()
|
||||
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||
}
|
||||
|
||||
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
|
||||
if (assetsByAlbums.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final companions = <TrashedLocalAssetEntityCompanion>[];
|
||||
final idToDelete = <String>{};
|
||||
|
||||
for (final entry in assetsByAlbums.entries) {
|
||||
for (final asset in entry.value) {
|
||||
idToDelete.add(asset.id);
|
||||
companions.add(
|
||||
TrashedLocalAssetEntityCompanion(
|
||||
id: Value(asset.id),
|
||||
name: Value(asset.name),
|
||||
albumId: Value(entry.key),
|
||||
checksum: Value(asset.checksum),
|
||||
type: Value(asset.type),
|
||||
width: Value(asset.width),
|
||||
height: Value(asset.height),
|
||||
durationMs: Value(asset.durationMs),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
orientation: Value(asset.orientation),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
createdAt: Value(asset.createdAt),
|
||||
updatedAt: Value(asset.updatedAt),
|
||||
source: const Value(TrashOrigin.remoteSync),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> applyRestoredAssets(List<String> idList) async {
|
||||
if (idList.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final trashedAssets = <TrashedLocalAssetEntityData>[];
|
||||
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
final q = _db.select(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice));
|
||||
trashedAssets.addAll(await q.get());
|
||||
}
|
||||
|
||||
if (trashedAssets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companions = trashedAssets.map((e) {
|
||||
return LocalAssetEntityCompanion.insert(
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
createdAt: Value(e.createdAt),
|
||||
updatedAt: Value(e.updatedAt),
|
||||
width: Value(e.width),
|
||||
height: Value(e.height),
|
||||
durationMs: Value(e.durationMs),
|
||||
checksum: Value(e.checksum),
|
||||
isFavorite: Value(e.isFavorite),
|
||||
orientation: Value(e.orientation),
|
||||
playbackStyle: Value(e.playbackStyle),
|
||||
);
|
||||
});
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.localAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> applyTrashedAssets(List<String> idList) async {
|
||||
if (idList.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final trashedAssets = <({LocalAssetEntityData asset, String albumId})>[];
|
||||
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
final rows = await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
])..where(_db.localAlbumAssetEntity.assetId.isIn(slice))).get();
|
||||
|
||||
final assetsWithAlbum = rows.map(
|
||||
(row) =>
|
||||
(albumId: row.readTable(_db.localAlbumAssetEntity).albumId, asset: row.readTable(_db.localAssetEntity)),
|
||||
);
|
||||
|
||||
trashedAssets.addAll(assetsWithAlbum);
|
||||
}
|
||||
|
||||
if (trashedAssets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final companions = trashedAssets.map((e) {
|
||||
return TrashedLocalAssetEntityCompanion.insert(
|
||||
id: e.asset.id,
|
||||
name: e.asset.name,
|
||||
type: e.asset.type,
|
||||
createdAt: Value(e.asset.createdAt),
|
||||
updatedAt: Value(e.asset.updatedAt),
|
||||
width: Value(e.asset.width),
|
||||
height: Value(e.asset.height),
|
||||
durationMs: Value(e.asset.durationMs),
|
||||
checksum: Value(e.asset.checksum),
|
||||
isFavorite: Value(e.asset.isFavorite),
|
||||
orientation: Value(e.asset.orientation),
|
||||
playbackStyle: Value(e.asset.playbackStyle),
|
||||
source: TrashOrigin.localUser,
|
||||
albumId: e.albumId,
|
||||
);
|
||||
});
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final companion in companions) {
|
||||
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
|
||||
}
|
||||
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getToTrash() async {
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||
),
|
||||
])..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.deletedAt.isNotNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
//attempt to reuse existing checksums
|
||||
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
|
||||
final localChecksumById = <String, String>{};
|
||||
|
||||
for (final slice in assetIds.slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.selectOnly(_db.localAssetEntity)
|
||||
..where(_db.localAssetEntity.id.isIn(slice) & _db.localAssetEntity.checksum.isNotNull())
|
||||
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum]))
|
||||
.get();
|
||||
|
||||
for (final r in rows) {
|
||||
localChecksumById[r.read(_db.localAssetEntity.id)!] = r.read(_db.localAssetEntity.checksum)!;
|
||||
}
|
||||
}
|
||||
|
||||
return localChecksumById;
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
await backgroundManager.syncTrash();
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
}
|
||||
|
||||
228
mobile/lib/platform/asset_media_api.g.dart
generated
Normal file
228
mobile/lib/platform/asset_media_api.g.dart
generated
Normal file
@@ -0,0 +1,228 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (identical(a, b)) {
|
||||
return true;
|
||||
}
|
||||
if (a is double && b is double) {
|
||||
if (a.isNaN && b.isNaN) {
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (final MapEntry<Object?, Object?> entryA in a.entries) {
|
||||
bool found = false;
|
||||
for (final MapEntry<Object?, Object?> entryB in b.entries) {
|
||||
if (_deepEquals(entryA.key, entryB.key)) {
|
||||
if (_deepEquals(entryA.value, entryB.value)) {
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
int _deepHash(Object? value) {
|
||||
if (value is List) {
|
||||
return Object.hashAll(value.map(_deepHash));
|
||||
}
|
||||
if (value is Map) {
|
||||
int result = 0;
|
||||
for (final MapEntry<Object?, Object?> entry in value.entries) {
|
||||
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value is double && value.isNaN) {
|
||||
// Normalize NaN to a consistent hash.
|
||||
return 0x7FF8000000000000.hashCode;
|
||||
}
|
||||
if (value is double && value == 0.0) {
|
||||
// Normalize -0.0 to 0.0 so they have the same hash code.
|
||||
return 0.0.hashCode;
|
||||
}
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
enum AssetMediaActionStatus { done, alreadyInState, notFound, failed }
|
||||
|
||||
class AssetMediaActionResult {
|
||||
AssetMediaActionResult({required this.id, required this.status});
|
||||
|
||||
String id;
|
||||
|
||||
AssetMediaActionStatus status;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[id, status];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static AssetMediaActionResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return AssetMediaActionResult(id: result[0]! as String, status: result[1]! as AssetMediaActionStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! AssetMediaActionResult || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) && _deepEquals(status, other.status);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is AssetMediaActionStatus) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is AssetMediaActionResult) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : AssetMediaActionStatus.values[value];
|
||||
case 130:
|
||||
return AssetMediaActionResult.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AssetMediaApi {
|
||||
/// Constructor for [AssetMediaApi]. 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.
|
||||
AssetMediaApi({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<List<AssetMediaActionResult>> trash(List<String> ids) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.AssetMediaApi.trash$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ids]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<AssetMediaActionResult>();
|
||||
}
|
||||
|
||||
Future<List<AssetMediaActionResult>> restore(List<String> ids) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.AssetMediaApi.restore$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ids]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<AssetMediaActionResult>();
|
||||
}
|
||||
|
||||
Future<List<String>> trashedAmong(List<String> ids) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.AssetMediaApi.trashedAmong$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ids]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
||||
}
|
||||
}
|
||||
38
mobile/lib/platform/native_sync_api.g.dart
generated
38
mobile/lib/platform/native_sync_api.g.dart
generated
@@ -649,44 +649,6 @@ class NativeSyncApi {
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
}
|
||||
|
||||
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$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 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 {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
|
||||
@@ -126,6 +126,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
await _safeRun(backgroundManager.syncTrash(), "syncTrash");
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
@@ -15,10 +14,6 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_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/connectivity_api.g.dart';
|
||||
@@ -17,6 +18,8 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
|
||||
|
||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final assetMediaApiProvider = Provider<AssetMediaApi>((_) => AssetMediaApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
@@ -11,8 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
|
||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||
|
||||
@@ -20,10 +19,7 @@ final syncStreamServiceProvider = Provider(
|
||||
(ref) => SyncStreamService(
|
||||
syncApiRepository: ref.watch(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
|
||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||
api: ref.watch(apiServiceProvider),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
@@ -38,9 +34,7 @@ final localSyncServiceProvider = Provider(
|
||||
(ref) => LocalSyncService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
),
|
||||
@@ -51,7 +45,6 @@ final hashServiceProvider = Provider(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.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/settings.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
typedef TrashedAssetsCount = ({int total, int hashed});
|
||||
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>(
|
||||
(ref) => DriftTrashSyncRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
|
||||
final repo = ref.watch(trashedLocalAssetRepository);
|
||||
final total$ = repo.watchCount();
|
||||
final hashed$ = repo.watchHashedCount();
|
||||
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
|
||||
});
|
||||
final trashSyncServiceProvider = Provider<TrashSyncService>(
|
||||
(ref) => TrashSyncService(
|
||||
repo: ref.watch(trashSyncRepositoryProvider),
|
||||
assetMediaApi: ref.watch(assetMediaApiProvider),
|
||||
permission: ref.watch(permissionRepositoryProvider),
|
||||
settings: ref.watch(settingsProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,8 +11,6 @@ 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/platform_extensions.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/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
@@ -25,15 +23,14 @@ import 'package:share_plus/share_plus.dart';
|
||||
typedef _ShareFile = ({File file, bool cleanup, String displayName});
|
||||
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider), ref.watch(storageRepositoryProvider)),
|
||||
(ref) => AssetMediaRepository(ref.watch(storageRepositoryProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final StorageRepository _storageRepository;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._nativeSyncApi, this._storageRepository);
|
||||
const AssetMediaRepository(this._storageRepository);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -58,27 +55,6 @@ class AssetMediaRepository {
|
||||
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 {
|
||||
final entity = await AssetEntity.fromId(id);
|
||||
return entity;
|
||||
|
||||
@@ -6,14 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.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/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
@@ -34,7 +30,6 @@ final actionServiceProvider = Provider<ActionService>(
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(trashedLocalAssetRepository),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(downloadRepositoryProvider),
|
||||
ref.watch(tagServiceProvider),
|
||||
@@ -47,7 +42,6 @@ class ActionService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final DownloadRepository _downloadRepository;
|
||||
final TagService _tagService;
|
||||
@@ -58,7 +52,6 @@ class ActionService {
|
||||
this._localAssetRepository,
|
||||
this._albumApiRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._trashedLocalAssetRepository,
|
||||
this._assetMediaRepository,
|
||||
this._downloadRepository,
|
||||
this._tagService,
|
||||
@@ -318,11 +311,7 @@ class ActionService {
|
||||
if (deletedIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
|
||||
} else {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
}
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
return deletedIds.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
enum AppSettingsEnum<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
const int targetVersion = 26;
|
||||
const int targetVersion = 27;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
final int version = Store.get(StoreKey.version, targetVersion);
|
||||
@@ -31,6 +31,10 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
|
||||
await _migrateTo26(drift);
|
||||
}
|
||||
|
||||
if (version < 27) {
|
||||
await _migrateTo27(drift);
|
||||
}
|
||||
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
}
|
||||
@@ -138,6 +142,12 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
await migrator.complete();
|
||||
}
|
||||
|
||||
Future<void> _migrateTo27(Drift drift) async {
|
||||
final migrator = _StoreMigrator(drift);
|
||||
await migrator.migrateBool(.legacyManageLocalMediaAndroid, .trashSyncEnabled);
|
||||
await migrator.complete();
|
||||
}
|
||||
|
||||
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
|
||||
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
|
||||
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
|
||||
|
||||
@@ -11,15 +11,16 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/feature_message.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
@@ -179,64 +180,71 @@ class LoginForm extends HookConsumerWidget {
|
||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||
|
||||
await backgroundManager.syncLocal(full: true);
|
||||
await backgroundManager.syncRemote();
|
||||
final syncSuccess = await backgroundManager.syncRemote();
|
||||
await viewIntentHandler.flushDeferredViewIntent();
|
||||
await backgroundManager.hashAssets();
|
||||
if (syncSuccess) {
|
||||
await backgroundManager.syncTrash();
|
||||
}
|
||||
|
||||
if (SettingsRepository.instance.appConfig.backup.syncAlbums) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
}
|
||||
|
||||
getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
'manage_media_access_title',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
|
||||
).tr(),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
|
||||
const SizedBox(height: 4),
|
||||
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
|
||||
],
|
||||
Future<void> promptManageMediaIfNeeded() async {
|
||||
if (!CurrentPlatform.isAndroid || !ref.read(appConfigProvider).trashSyncEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final permission = ref.read(permissionRepositoryProvider);
|
||||
if (await permission.hasManageMediaPermission() || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(10))),
|
||||
elevation: 5,
|
||||
title: Text(
|
||||
context.t.manage_media_access_title,
|
||||
style: .new(fontSize: 16, fontWeight: .bold, color: context.primaryColor),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: ListBody(
|
||||
children: [
|
||||
Text(context.t.manage_media_access_subtitle, style: const .new(fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
Text(context.t.manage_media_access_rationale, style: const .new(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
context.t.cancel,
|
||||
style: .new(fontWeight: .w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'cancel'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
unawaited(permission.requestManageMediaPermission());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
context.t.manage_media_access_settings,
|
||||
style: .new(fontWeight: .w600, color: context.primaryColor),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'manage_media_access_settings'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
|
||||
|
||||
login() async {
|
||||
TextInput.finishAutofillContext();
|
||||
|
||||
@@ -250,9 +258,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
unawaited(context.pushRoute(const ChangePasswordRoute()));
|
||||
} else {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
await promptManageMediaIfNeeded();
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(ref.read(featureMessageServiceProvider).markSeen());
|
||||
@@ -339,9 +345,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
if (isSuccess) {
|
||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||
if (isSyncRemoteDeletionsMode()) {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
await promptManageMediaIfNeeded();
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(ref.read(featureMessageServiceProvider).markSeen());
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
|
||||
@@ -7,9 +7,9 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.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/settings.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
@@ -28,7 +28,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final trashSyncEnabled = useState(ref.read(appConfigProvider).trashSyncEnabled);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
|
||||
@@ -75,14 +75,16 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
children: [
|
||||
SettingsSwitchListTile(
|
||||
enabled: true,
|
||||
valueNotifier: manageLocalMediaAndroid,
|
||||
valueNotifier: trashSyncEnabled,
|
||||
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||
onChanged: (value) async {
|
||||
trashSyncEnabled.value = value;
|
||||
await ref.read(settingsProvider).write(.trashSyncEnabled, value);
|
||||
if (value) {
|
||||
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
manageMediaAndroidPermission.value = result;
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(permissionRepositoryProvider)
|
||||
.requestManageMediaPermission();
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -90,12 +92,14 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
title: "manage_media_access_title".tr(),
|
||||
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
|
||||
subtitle: "manage_media_access_rationale".tr(),
|
||||
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
|
||||
statusColor: trashSyncEnabled.value && !manageMediaAndroidPermission.value
|
||||
? const Color.fromARGB(255, 243, 188, 106)
|
||||
: null,
|
||||
onActionTap: () async {
|
||||
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = result;
|
||||
await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(permissionRepositoryProvider)
|
||||
.hasManageMediaPermission();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -7,17 +7,14 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
|
||||
@@ -219,7 +216,6 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||
final localAlbumService = ref.watch(localAlbumServiceProvider);
|
||||
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
|
||||
final memoryService = ref.watch(driftMemoryServiceProvider);
|
||||
final appSettingsService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
Future<List<dynamic>> loadCounts() async {
|
||||
final assetCounts = assetService.getAssetCounts();
|
||||
@@ -353,47 +349,6 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// To be removed once the experimental feature is stable
|
||||
if (CurrentPlatform.isAndroid &&
|
||||
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
|
||||
SettingGroupTitle(title: "trash".t(context: context)),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final counts = ref.watch(trashedAssetsCountProvider);
|
||||
return counts.when(
|
||||
data: (c) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: IntrinsicHeight(
|
||||
child: Flex(
|
||||
direction: Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
Expanded(
|
||||
child: EntityCountTile(
|
||||
label: "local".t(context: context),
|
||||
count: c.total,
|
||||
icon: Icons.delete_outline,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: EntityCountTile(
|
||||
label: "hashed_assets".t(context: context),
|
||||
count: c.hashed,
|
||||
icon: Icons.tag,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, st) => Text('Error: $e'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
33
mobile/pigeon/asset_media_api.dart
Normal file
33
mobile/pigeon/asset_media_api.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/asset_media_api.g.dart',
|
||||
swiftOut: 'ios/Runner/AssetMedia/AssetMedia.g.swift',
|
||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/media/AssetMedia.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.media'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
enum AssetMediaActionStatus { done, alreadyInState, notFound, failed }
|
||||
|
||||
class AssetMediaActionResult {
|
||||
final String id;
|
||||
final AssetMediaActionStatus status;
|
||||
|
||||
const AssetMediaActionResult({required this.id, required this.status});
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class AssetMediaApi {
|
||||
@async
|
||||
List<AssetMediaActionResult> trash(List<String> ids);
|
||||
|
||||
@async
|
||||
List<AssetMediaActionResult> restore(List<String> ids);
|
||||
|
||||
@async
|
||||
List<String> trashedAmong(List<String> ids);
|
||||
}
|
||||
@@ -135,12 +135,6 @@ abstract class NativeSyncApi {
|
||||
|
||||
void cancelSync();
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||
|
||||
@async
|
||||
bool restoreFromTrashById(String mediaId, int type);
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -13,6 +15,10 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||
|
||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
class MockAssetMediaApi extends Mock implements AssetMediaApi {}
|
||||
|
||||
class MockTrashSyncService extends Mock implements TrashSyncService {}
|
||||
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockPartnerService extends Mock implements PartnerService {}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -11,23 +10,17 @@ 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_asset.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/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late LocalSyncService sut;
|
||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||
late AssetMediaRepository mockAssetMediaRepository;
|
||||
late MockPermissionRepository mockPermissionRepository;
|
||||
late MockTrashSyncRepository mockTrashSyncRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late Drift db;
|
||||
|
||||
@@ -48,175 +41,29 @@ void main() {
|
||||
setUp(() async {
|
||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
mockPermissionRepository = MockPermissionRepository();
|
||||
mockTrashSyncRepository = MockTrashSyncRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
|
||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
|
||||
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
|
||||
);
|
||||
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
when(() => mockTrashSyncRepository.restoreChecksums()).thenAnswer((_) async {});
|
||||
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
assetMediaRepository: mockAssetMediaRepository,
|
||||
permissionRepository: mockPermissionRepository,
|
||||
trashSyncRepository: mockTrashSyncRepository,
|
||||
nativeSyncApi: mockNativeSyncApi,
|
||||
);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
group('LocalSyncService - no changes', () {
|
||||
test('does nothing when there are no media changes', () async {
|
||||
await sut.sync();
|
||||
|
||||
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets on non-Android platforms', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets behavior', () {
|
||||
test('processes trashed snapshot, restores assets, and trashes local files', () async {
|
||||
final platformAsset = PlatformAsset(
|
||||
id: 'remote-id',
|
||||
name: 'remote.jpg',
|
||||
type: AssetType.image.index,
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||
final restoredIds = ['image1'];
|
||||
when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requested, orderedEquals(assetsToRestore));
|
||||
return restoredIds;
|
||||
});
|
||||
|
||||
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [localAssetToTrash],
|
||||
},
|
||||
);
|
||||
|
||||
await sut.processTrashedAssets({
|
||||
'album-a': [platformAsset],
|
||||
});
|
||||
|
||||
final trashedSnapshot =
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
expect(trashedSnapshot.length, 1);
|
||||
final trashedEntry = trashedSnapshot.single;
|
||||
expect(trashedEntry.albumId, 'album-a');
|
||||
expect(trashedEntry.asset.id, platformAsset.id);
|
||||
expect(trashedEntry.asset.name, platformAsset.name);
|
||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||
|
||||
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||
|
||||
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['local-trash']);
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
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 {
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
final trashedSnapshot =
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
expect(trashedSnapshot, isEmpty);
|
||||
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||
});
|
||||
|
||||
test('does not move local assets when repository finds nothing to trash', () async {
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||
verify(() => mockNativeSyncApi.getMediaChanges()).called(1);
|
||||
verifyNever(() => mockLocalAlbumRepository.updateAll(any()));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,19 +4,15 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.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_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -25,7 +21,6 @@ import '../../api.mocks.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../fixtures/sync_stream.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
class _AbortCallbackWrapper {
|
||||
@@ -36,15 +31,11 @@ class _AbortCallbackWrapper {
|
||||
|
||||
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
|
||||
|
||||
|
||||
void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
late SyncApiRepository mockSyncApiRepo;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||
late AssetMediaRepository mockAssetMediaRepo;
|
||||
late MockPermissionRepository mockPermissionRepo;
|
||||
late MockTrashSyncRepository mockTrashSyncRepo;
|
||||
late MockApiService mockApi;
|
||||
late MockServerApi mockServerApi;
|
||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||
@@ -52,7 +43,6 @@ void main() {
|
||||
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
|
||||
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
|
||||
late Drift db;
|
||||
late bool hasManageMediaPermission;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -75,10 +65,7 @@ void main() {
|
||||
setUp(() async {
|
||||
mockSyncStreamRepo = MockSyncStreamRepository();
|
||||
mockSyncApiRepo = MockSyncApiRepository();
|
||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||
mockAssetMediaRepo = MockAssetMediaRepository();
|
||||
mockPermissionRepo = MockPermissionRepository();
|
||||
mockTrashSyncRepo = MockTrashSyncRepository();
|
||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockApi = MockApiService();
|
||||
@@ -153,26 +140,12 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
trashSyncRepository: mockTrashSyncRepo,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
hasManageMediaPermission = false;
|
||||
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockTrashSyncRepo.recordHardDeletedChecksums(any())).thenAnswer((_) => Future.value());
|
||||
});
|
||||
|
||||
Future<void> simulateEvents(List<SyncEvent> events) async {
|
||||
@@ -205,6 +178,13 @@ void main() {
|
||||
verifyNever(() => mockAbortCallbackWrapper());
|
||||
});
|
||||
|
||||
test("assetDeleteV1 records the server deleted checksums", () async {
|
||||
await simulateEvents([SyncStreamStub.assetDeleteV1]);
|
||||
|
||||
verify(() => mockTrashSyncRepo.recordHardDeletedChecksums(any())).called(1);
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
test("processes final batch correctly", () async {
|
||||
final events = [SyncStreamStub.userDeleteV1, SyncStreamStub.userV1Admin];
|
||||
|
||||
@@ -236,10 +216,7 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
trashSyncRepository: mockTrashSyncRepo,
|
||||
cancellation: cancellation,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -276,10 +253,7 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
trashSyncRepository: mockTrashSyncRepo,
|
||||
cancellation: cancellation,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -392,164 +366,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group("SyncStreamService - remote trash & restore", () {
|
||||
setUp(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
hasManageMediaPermission = true;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
hasManageMediaPermission = false;
|
||||
});
|
||||
|
||||
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
|
||||
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
|
||||
final mergedAsset = LocalAssetStub.image2.copyWith(
|
||||
id: 'merged-local',
|
||||
checksum: 'checksum-merged',
|
||||
remoteId: 'remote-merged',
|
||||
);
|
||||
final assetsByAlbum = {
|
||||
'album-a': [localAsset],
|
||||
'album-b': [mergedAsset],
|
||||
};
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
expect(ids, unorderedEquals(['local-only', 'merged-local']));
|
||||
return ids;
|
||||
});
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-1',
|
||||
checksum: localAsset.checksum!,
|
||||
ack: 'asset-remote-local-1',
|
||||
trashedAt: DateTime(2025, 5, 1),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-2',
|
||||
checksum: mergedAsset.checksum!,
|
||||
ack: 'asset-remote-merged-2',
|
||||
trashedAt: DateTime(2025, 5, 2),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-3',
|
||||
checksum: 'checksum-remote-only',
|
||||
ack: 'asset-remote-only-3',
|
||||
trashedAt: DateTime(2025, 5, 3),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 {
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-only',
|
||||
checksum: 'checksum-only',
|
||||
ack: 'asset-remote-only-9',
|
||||
trashedAt: DateTime(2025, 6, 1),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
});
|
||||
|
||||
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
|
||||
return {};
|
||||
});
|
||||
|
||||
final events = [SyncStreamStub.assetDeleteV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
test("restores trashed local assets once the matching remote assets leave the trash", () async {
|
||||
final trashedAssets = [
|
||||
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
|
||||
];
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||
|
||||
final restoredIds = ['trashed-1'];
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||
return restoredIds;
|
||||
});
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncStreamService - Sync Migration', () {
|
||||
test('ensure that <2.5.0 migrations run', () async {
|
||||
await Store.put(StoreKey.syncMigrationStatus, "[]");
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -35,6 +35,7 @@ import 'schema_v28.dart' as v28;
|
||||
import 'schema_v29.dart' as v29;
|
||||
import 'schema_v30.dart' as v30;
|
||||
import 'schema_v31.dart' as v31;
|
||||
import 'schema_v32.dart' as v32;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -102,6 +103,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v30.DatabaseAtV30(db);
|
||||
case 31:
|
||||
return v31.DatabaseAtV31(db);
|
||||
case 32:
|
||||
return v32.DatabaseAtV32(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -139,5 +142,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
32,
|
||||
];
|
||||
}
|
||||
|
||||
9792
mobile/test/drift/main/generated/schema_v32.dart
generated
Normal file
9792
mobile/test/drift/main/generated/schema_v32.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ 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_migration.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/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
@@ -36,7 +36,7 @@ class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepos
|
||||
|
||||
class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
||||
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
class MockTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
@@ -114,6 +116,32 @@ void main() {
|
||||
expect(result.remainder, 1);
|
||||
});
|
||||
|
||||
test('excludes asset with a trash sync row from counts', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(TrashSyncEntityCompanion.insert(assetId: asset.id, checksum: 'trashed'));
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('excludes an asset whose content the server permanently deleted', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksum: 'deleted');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: 'deleted'));
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('mixed assets produce correct combined counts', () async {
|
||||
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
|
||||
@@ -229,6 +257,30 @@ void main() {
|
||||
expect(result.map((a) => a.id).toList(), [asset2.id, asset3.id, asset1.id]);
|
||||
});
|
||||
|
||||
test('excludes asset with a trash sync row', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(TrashSyncEntityCompanion.insert(assetId: asset.id, checksum: 'trashed'));
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes an asset whose content the server permanently deleted', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksum: 'deleted');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
await ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: 'deleted'));
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('does not return duplicate when asset is in multiple selected albums', () async {
|
||||
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
|
||||
505
mobile/test/medium/repositories/trash_sync_repository_test.dart
Normal file
505
mobile/test/medium/repositories/trash_sync_repository_test.dart
Normal file
@@ -0,0 +1,505 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/server_deleted_checksum.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftTrashSyncRepository sut;
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftTrashSyncRepository(ctx.db);
|
||||
});
|
||||
|
||||
tearDown(() => ctx.dispose());
|
||||
|
||||
Future<({String localId, String checksum, String remoteId})> backedUpAsset({
|
||||
required String ownerId,
|
||||
DateTime? remoteDeletedAt,
|
||||
BackupSelection album = .selected,
|
||||
}) async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: ownerId, deletedAt: remoteDeletedAt);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final localAlbum = await ctx.newLocalAlbum(backupSelection: album);
|
||||
await ctx.newLocalAlbumAsset(albumId: localAlbum.id, assetId: local.id);
|
||||
return (localId: local.id, checksum: remote.checksum, remoteId: remote.id);
|
||||
}
|
||||
|
||||
Future<TrashSyncStatus?> trashStatusOf(String assetId) async {
|
||||
final rows = await ctx.db.select(ctx.db.trashSyncEntity).get();
|
||||
return rows.where((r) => r.assetId == assetId).map((r) => r.status).firstOrNull;
|
||||
}
|
||||
|
||||
Future<void> markAsset({
|
||||
required String assetId,
|
||||
required String checksum,
|
||||
required TrashSyncStatus status,
|
||||
DateTime? assetUpdatedAt,
|
||||
}) => ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(
|
||||
TrashSyncEntityCompanion.insert(
|
||||
assetId: assetId,
|
||||
checksum: checksum,
|
||||
status: Value(status),
|
||||
assetUpdatedAt: Value.absentIfNull(assetUpdatedAt),
|
||||
),
|
||||
);
|
||||
|
||||
Future<String?> checksumOf(String assetId) async {
|
||||
final rows = await ctx.db.select(ctx.db.localAssetEntity).get();
|
||||
return rows.where((r) => r.id == assetId).map((r) => r.checksum).firstOrNull;
|
||||
}
|
||||
|
||||
late String userId;
|
||||
setUp(() async {
|
||||
userId = (await ctx.newUser()).id;
|
||||
await ctx.newAuthUser(id: userId);
|
||||
});
|
||||
|
||||
group('recordSoftDeleteAssets', () {
|
||||
test('records marker when server asset is trashed', () async {
|
||||
final asset = await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), [asset.localId]);
|
||||
});
|
||||
|
||||
test('#24124: asset on server is never trashed', () async {
|
||||
await backedUpAsset(ownerId: userId, remoteDeletedAt: null);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores assets that are not in a backup selected album', () async {
|
||||
await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1), album: .none);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('existing marker is not duplicated', () async {
|
||||
await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), hasLength(1));
|
||||
});
|
||||
|
||||
test('#24124: does not retrash a dismissed / restored asset', () async {
|
||||
final asset = await backedUpAsset(ownerId: userId, remoteDeletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: asset.localId, checksum: asset.checksum, status: .dismissed);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.dismissed);
|
||||
});
|
||||
});
|
||||
|
||||
group('getRestorableAssetIds', () {
|
||||
test('should mark asset restorable when asset is back on server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), ['asset']);
|
||||
});
|
||||
|
||||
test('pending assets are not restored', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('do not mark asset restorable when server asset is still trashed', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('do not mark asset restorable when server asset is permanently deleted', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('duplicate local copies of the same asset', () {
|
||||
test('recordSoftDeleteAssets records one marker per local copy', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final local1 = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final local2 = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local1.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local2.id);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), unorderedEquals([local1.id, local2.id]));
|
||||
});
|
||||
|
||||
test('getRestorableAssetIds returns every trashed copy sharing the revived checksum', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'local1', checksum: remote.checksum, status: .trashed);
|
||||
await markAsset(assetId: 'local2', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), unorderedEquals(['local1', 'local2']));
|
||||
});
|
||||
});
|
||||
|
||||
group('pruneStaleMarkers - asset came back before trashing', () {
|
||||
test('removes marker when asset is back on server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('dismissed marker is kept even after asset is back on server to prevent re-trashing it', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('keeps a pending marker for asset that is still deleted', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
});
|
||||
|
||||
test('does not remove a trashed marker', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.trashed);
|
||||
});
|
||||
});
|
||||
|
||||
group('excluded album handling', () {
|
||||
test('recordSoftDeleteAssets ignores an asset also in an excluded album', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: .excluded);
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: local.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: local.id);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('duplicate assets', () {
|
||||
test('recordSoftDeleteAssets skips duplicate asset when previous asset is dismissed', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final reimport = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: reimport.id);
|
||||
await markAsset(assetId: 'old-asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('prunePendingMarkers', () {
|
||||
test('removes pending marker when local asset is modified', () async {
|
||||
final local = await ctx.newLocalAsset(checksum: 'current-checksum');
|
||||
await markAsset(assetId: local.id, checksum: 'marked-checksum', status: .pending);
|
||||
|
||||
await sut.prunePendingMarkers();
|
||||
|
||||
expect(await trashStatusOf(local.id), isNull);
|
||||
});
|
||||
|
||||
test('keeps pending marker for local asset', () async {
|
||||
final local = await ctx.newLocalAsset(checksum: 'checksum');
|
||||
await markAsset(assetId: local.id, checksum: 'checksum', status: .pending);
|
||||
|
||||
await sut.prunePendingMarkers();
|
||||
|
||||
expect(await trashStatusOf(local.id), TrashSyncStatus.pending);
|
||||
});
|
||||
|
||||
test('keeps pending marker for missing local asset', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .pending);
|
||||
|
||||
await sut.prunePendingMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
});
|
||||
});
|
||||
|
||||
group('server deleted checksum table', () {
|
||||
Future<void> insertDeletedChecksum(String checksum) => ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: checksum));
|
||||
|
||||
Future<List<String>> deletedChecksums() async =>
|
||||
(await ctx.db.select(ctx.db.serverDeletedChecksumEntity).get()).map((r) => r.checksum).toList();
|
||||
|
||||
group('recordHardDeletedChecksums', () {
|
||||
test('marks owned permanently deleted assets', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId);
|
||||
|
||||
await sut.recordHardDeletedChecksums([remote.id]);
|
||||
|
||||
expect(await deletedChecksums(), [remote.checksum]);
|
||||
});
|
||||
|
||||
test('ignores a partner assets', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id);
|
||||
|
||||
await sut.recordHardDeletedChecksums([remote.id]);
|
||||
|
||||
expect(await deletedChecksums(), isEmpty);
|
||||
});
|
||||
|
||||
test('only marks local assets from backup selected album', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final local = await ctx.newLocalAsset(checksum: 'checksum');
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
await insertDeletedChecksum('checksum');
|
||||
|
||||
await sut.recordHardDeletedAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), [local.id]);
|
||||
});
|
||||
|
||||
test('ignores an excluded album asset', () async {
|
||||
final selected = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final excluded = await ctx.newLocalAlbum(backupSelection: .excluded);
|
||||
final local = await ctx.newLocalAsset(checksum: 'checksum');
|
||||
await ctx.newLocalAlbumAsset(albumId: selected.id, assetId: local.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excluded.id, assetId: local.id);
|
||||
await insertDeletedChecksum('checksum');
|
||||
|
||||
await sut.recordHardDeletedAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruneStaleMarkers', () {
|
||||
test('removes marker for asset back on the server', () async {
|
||||
await insertDeletedChecksum('checksum');
|
||||
await ctx.newRemoteAsset(ownerId: userId, deletedAt: null, checksum: 'checksum');
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await deletedChecksums(), isEmpty);
|
||||
});
|
||||
|
||||
test('keeps a checksum with no owned remote asset', () async {
|
||||
await insertDeletedChecksum('checksum');
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await deletedChecksums(), ['checksum']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('owner scoping', () {
|
||||
test('recordSoftDeleteAssets: ignores trashed assets from partner', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
await sut.recordSoftDeleteAssets();
|
||||
|
||||
expect(await sut.getPendingAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('getRestorableAssetIds: ignores restore when server remote asset is only partner owned', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
expect(await sut.getRestorableAssetIds(), isEmpty);
|
||||
});
|
||||
|
||||
test('pruneStaleMarkers: ignores remote asset that is only partner owned', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: null);
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .pending);
|
||||
|
||||
await sut.pruneStaleMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
});
|
||||
});
|
||||
|
||||
group('reconcileTrashed', () {
|
||||
test('dismisses a trashed marker whose local asset is not trashed', () async {
|
||||
final local = await ctx.newLocalAsset();
|
||||
await markAsset(assetId: local.id, checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.reconcileTrashed([local.id]);
|
||||
|
||||
expect(await trashStatusOf(local.id), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('removes a trashed marker whose local asset was deleted', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.reconcileTrashed(['asset']);
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('splits a mixed batch in one call: live id dismissed, purged id deleted', () async {
|
||||
final live = await ctx.newLocalAsset();
|
||||
await markAsset(assetId: live.id, checksum: 'checksum-1', status: .trashed);
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum-2', status: .trashed);
|
||||
|
||||
await sut.reconcileTrashed([live.id, 'asset']);
|
||||
|
||||
expect(await trashStatusOf(live.id), TrashSyncStatus.dismissed);
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruneDismissedMarkers', () {
|
||||
Future<void> insertDeletedChecksum(String checksum) => ctx.db
|
||||
.into(ctx.db.serverDeletedChecksumEntity)
|
||||
.insert(ServerDeletedChecksumEntityCompanion.insert(checksum: checksum));
|
||||
|
||||
test('removes a dismissed marker when its checksum is not server deleted', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('keeps dismissed marker still trashed on the server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('keeps dismissed marker for permanently deleted assets on server', () async {
|
||||
await insertDeletedChecksum('checksum');
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
});
|
||||
|
||||
test('removes dismissed marker of asset matching only partner trashed asset', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('do not delete trashed marker when server checksum is empty', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.pruneDismissedMarkers();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.trashed);
|
||||
});
|
||||
});
|
||||
|
||||
group('markTrashed', () {
|
||||
test('marks trashed and removes the local asset', () async {
|
||||
final local = await ctx.newLocalAsset();
|
||||
await markAsset(assetId: local.id, checksum: 'checksum', status: .pending);
|
||||
|
||||
await sut.markTrashed([local.id]);
|
||||
|
||||
expect(await trashStatusOf(local.id), TrashSyncStatus.trashed);
|
||||
final locals = await ctx.db.select(ctx.db.localAssetEntity).get();
|
||||
expect(locals.where((l) => l.id == local.id), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('markRestored', () {
|
||||
test('marks as restored, leaving the rows in place', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.markRestored(['asset']);
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.restored);
|
||||
});
|
||||
});
|
||||
|
||||
group('restoreChecksums', () {
|
||||
test('copies the stored checksum to local asset whose modified time is unchanged', () async {
|
||||
final modifiedAt = DateTime(2026, 1, 1);
|
||||
await ctx.newLocalAsset(id: 'asset', checksumOption: const .none(), updatedAt: modifiedAt);
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .restored, assetUpdatedAt: modifiedAt);
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await checksumOf('asset'), 'checksum');
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('leaves the checksum null when the asset was modified after restore', () async {
|
||||
await ctx.newLocalAsset(id: 'asset', checksumOption: const .none(), updatedAt: .new(2026, 1, 1));
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .restored, assetUpdatedAt: DateTime(2024, 3, 1));
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await checksumOf('asset'), isNull);
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('does not overwrite a checksum the hash pass already computed', () async {
|
||||
final modifiedAt = DateTime(2026, 1, 1);
|
||||
await ctx.newLocalAsset(id: 'asset', checksum: 'checksum', updatedAt: modifiedAt);
|
||||
await markAsset(assetId: 'asset', checksum: 'old-checksum', status: .restored, assetUpdatedAt: modifiedAt);
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await checksumOf('asset'), 'checksum');
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('clears restored markers even when local asset does not exist', () async {
|
||||
await markAsset(assetId: 'asset', checksum: 'checksum', status: .restored, assetUpdatedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.restoreChecksums();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
@@ -50,6 +51,20 @@ class MediumRepositoryContext {
|
||||
return .new(fallback);
|
||||
}
|
||||
|
||||
Future<AuthUserEntityData> newAuthUser({String? id, String? email}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
return db
|
||||
.into(db.authUserEntity)
|
||||
.insertReturning(
|
||||
AuthUserEntityCompanion(
|
||||
id: .new(id),
|
||||
email: .new(email ?? '$id@demo.immich.app'),
|
||||
name: .new(email ?? 'user_$id'),
|
||||
avatarColor: .new(TestUtils.randElement(AvatarColor.values)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<UserEntityData> newUser({
|
||||
String? id,
|
||||
String? email,
|
||||
@@ -63,7 +78,7 @@ class MediumRepositoryContext {
|
||||
.insertReturning(
|
||||
UserEntityCompanion(
|
||||
id: .new(id),
|
||||
email: .new(email ?? '$id@test.com'),
|
||||
email: .new(email ?? '$id@demo.immich.app'),
|
||||
name: .new(email ?? 'user_$id'),
|
||||
avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
|
||||
profileChangedAt: .new(TestUtils.date(profileChangedAt)),
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../api.mocks.dart';
|
||||
import '../domain/service.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
import '../utils.dart';
|
||||
import 'repository_context.dart';
|
||||
|
||||
class MediumServiceContext extends MediumRepositoryContext {
|
||||
late final UserRepository userRepository = UserRepository(db);
|
||||
late final PartnerRepository partnerRepository = PartnerRepository(db);
|
||||
late final DriftTrashSyncRepository trashSyncRepository = DriftTrashSyncRepository(db);
|
||||
late final SettingsRepository settings;
|
||||
|
||||
final partnerApi = MockPartnerApiRepository();
|
||||
final assetMediaApi = MockAssetMediaApi();
|
||||
final permissionRepository = MockPermissionRepository();
|
||||
|
||||
MediumServiceContext._() {
|
||||
_stubPartnerApi(partnerApi);
|
||||
_stubPermissionRepository(permissionRepository);
|
||||
_stubAssetMediaApi(assetMediaApi);
|
||||
}
|
||||
|
||||
static Future<MediumServiceContext> init() async {
|
||||
final context = MediumServiceContext._();
|
||||
context.settings = await SettingsRepository.ensureInitialized(context.db);
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await super.dispose();
|
||||
SettingsRepository.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void _stubPartnerApi(MockPartnerApiRepository api) {
|
||||
final id = TestUtils.uuid();
|
||||
final partner = UserDto(id: id, email: '$id@example.com', name: 'name $id', profileChangedAt: TestUtils.now());
|
||||
@@ -19,13 +52,13 @@ void _stubPartnerApi(MockPartnerApiRepository api) {
|
||||
when(() => api.delete(any())).thenAnswer((_) async {});
|
||||
}
|
||||
|
||||
class MediumServiceContext extends MediumRepositoryContext {
|
||||
late final UserRepository userRepository = UserRepository(db);
|
||||
late final PartnerRepository partnerRepository = PartnerRepository(db);
|
||||
|
||||
final partnerApi = MockPartnerApiRepository();
|
||||
|
||||
MediumServiceContext() {
|
||||
_stubPartnerApi(partnerApi);
|
||||
}
|
||||
void _stubPermissionRepository(MockPermissionRepository permission) {
|
||||
when(() => permission.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
}
|
||||
|
||||
void _stubAssetMediaApi(MockAssetMediaApi assetMediaApi) {
|
||||
registerFallbackValue(const <String>[]);
|
||||
when(() => assetMediaApi.trash(any())).thenAnswer((_) async => []);
|
||||
when(() => assetMediaApi.restore(any())).thenAnswer((_) async => []);
|
||||
when(() => assetMediaApi.trashedAmong(any())).thenAnswer((_) async => []);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ void main() {
|
||||
late MediumServiceContext ctx;
|
||||
late PartnerService sut;
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumServiceContext();
|
||||
setUp(() async {
|
||||
ctx = await MediumServiceContext.init();
|
||||
sut = PartnerService(ctx.userRepository, ctx.partnerRepository, ctx.partnerApi);
|
||||
});
|
||||
|
||||
|
||||
249
mobile/test/medium/services/trash_sync_service_test.dart
Normal file
249
mobile/test/medium/services/trash_sync_service_test.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/platform/asset_media_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../service_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumServiceContext ctx;
|
||||
late TrashSyncService sut;
|
||||
late String userId;
|
||||
|
||||
setUpAll(() => debugDefaultTargetPlatformOverride = .android);
|
||||
tearDownAll(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
setUp(() async {
|
||||
ctx = await MediumServiceContext.init();
|
||||
sut = TrashSyncService(
|
||||
repo: ctx.trashSyncRepository,
|
||||
assetMediaApi: ctx.assetMediaApi,
|
||||
permission: ctx.permissionRepository,
|
||||
settings: ctx.settings,
|
||||
);
|
||||
userId = (await ctx.newUser()).id;
|
||||
await ctx.newAuthUser(id: userId);
|
||||
await ctx.settings.write(.trashSyncEnabled, true);
|
||||
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.done));
|
||||
when(() => ctx.assetMediaApi.restore(any())).thenAnswer(_mapStatus(.done));
|
||||
when(() => ctx.assetMediaApi.trashedAmong(any())).thenAnswer((inv) async => _ids(inv));
|
||||
});
|
||||
|
||||
tearDown(() => ctx.dispose());
|
||||
|
||||
Future<({String localId, String remoteId, String checksum})> backedUpAsset({DateTime? remoteDeletedAt}) async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: remoteDeletedAt);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
return (localId: local.id, remoteId: remote.id, checksum: remote.checksum);
|
||||
}
|
||||
|
||||
Future<TrashSyncStatus?> trashStatusOf(String id) async {
|
||||
final rows = (await ctx.db.select(ctx.db.trashSyncEntity).get()).where((r) => r.assetId == id).toList();
|
||||
return rows.isEmpty ? null : rows.first.status;
|
||||
}
|
||||
|
||||
Future<bool> localAssetExists(String id) async =>
|
||||
(await ctx.db.select(ctx.db.localAssetEntity).get()).any((r) => r.id == id);
|
||||
|
||||
Future<void> markAsset({required String localId, required String checksum, TrashSyncStatus status = .pending}) async {
|
||||
await ctx.db
|
||||
.into(ctx.db.trashSyncEntity)
|
||||
.insert(TrashSyncEntityCompanion.insert(assetId: localId, checksum: checksum, status: .new(status)));
|
||||
}
|
||||
|
||||
test('trashed asset is recorded, trashed, and its local row pruned', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.trashed);
|
||||
expect(await localAssetExists(asset.localId), isFalse);
|
||||
});
|
||||
|
||||
test('trash sync disabled: trashed asset is left untouched', () async {
|
||||
await ctx.settings.write(.trashSyncEnabled, false);
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('trash pending asset that is removed from device is not trashed and marker is removed', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.notFound));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
});
|
||||
|
||||
test('partner trash does not affect our local copy', () async {
|
||||
final partner = await ctx.newUser();
|
||||
final remote = await ctx.newRemoteAsset(ownerId: partner.id, deletedAt: .new(2026, 1, 1));
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(local.id), isNull);
|
||||
expect(await localAssetExists(local.id), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('trash pending marker is removed when the asset is back', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: null);
|
||||
await markAsset(localId: asset.localId, checksum: asset.checksum);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('reupload after a failed trash cleans the marker', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: .new(2026, 1, 1));
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.failed));
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.pending);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
|
||||
await (ctx.db.update(
|
||||
ctx.db.remoteAssetEntity,
|
||||
)..where((t) => t.id.equals(asset.remoteId))).write(const RemoteAssetEntityCompanion(deletedAt: .new(null)));
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.done));
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), isNull);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
});
|
||||
|
||||
test('server delete is also sent to the OS trash', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: null);
|
||||
await ctx.trashSyncRepository.recordHardDeletedChecksums([asset.remoteId]);
|
||||
await (ctx.db.delete(ctx.db.remoteAssetEntity)..where((t) => t.id.equals(asset.remoteId))).go();
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.trashed);
|
||||
expect(await localAssetExists(asset.localId), isFalse);
|
||||
});
|
||||
|
||||
test('trashed asset permanently deleted from the server stays trashed on device', () async {
|
||||
await markAsset(localId: 'asset', checksum: 'checksum', status: .trashed);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.trashed);
|
||||
verifyNever(() => ctx.assetMediaApi.restore(any()));
|
||||
});
|
||||
|
||||
test('permanently deleted asset, that was externally trashed and restored is marked as dismissed', () async {
|
||||
final asset = await backedUpAsset(remoteDeletedAt: null);
|
||||
await ctx.trashSyncRepository.recordHardDeletedChecksums([asset.remoteId]);
|
||||
await (ctx.db.delete(ctx.db.remoteAssetEntity)..where((t) => t.id.equals(asset.remoteId))).go();
|
||||
when(() => ctx.assetMediaApi.trash(any())).thenAnswer(_mapStatus(.alreadyInState));
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.trashed);
|
||||
expect(await localAssetExists(asset.localId), isFalse);
|
||||
|
||||
await ctx.newLocalAsset(id: asset.localId, checksum: asset.checksum);
|
||||
when(() => ctx.assetMediaApi.trashedAmong(any())).thenAnswer((_) async => const <String>[]);
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf(asset.localId), TrashSyncStatus.dismissed);
|
||||
expect(await localAssetExists(asset.localId), isTrue);
|
||||
});
|
||||
|
||||
test('dismissed guard prevents assets from being re-trashed again', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: .new(2026, 1, 1));
|
||||
final album = await ctx.newLocalAlbum(backupSelection: .selected);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
|
||||
final local = await ctx.newLocalAsset(id: 'asset', checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.dismissed);
|
||||
expect(await localAssetExists('asset'), isTrue);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
|
||||
test('dismissed guard is pruned once its asset is back on the server', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .dismissed);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('restored assets are restored locally and marked for backfill', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
verify(() => ctx.assetMediaApi.restore(['asset'])).called(1);
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.restored);
|
||||
});
|
||||
|
||||
test('restorable asset with not found status has its marker dropped', () async {
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId, deletedAt: null);
|
||||
await markAsset(localId: 'asset', checksum: remote.checksum, status: .trashed);
|
||||
when(() => ctx.assetMediaApi.restore(any())).thenAnswer(_mapStatus(.notFound));
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), isNull);
|
||||
});
|
||||
|
||||
test('backlog handled when MANAGE_MEDIA was off and handled when on', () async {
|
||||
await markAsset(localId: 'asset1', checksum: 'checksum1');
|
||||
await markAsset(localId: 'asset2', checksum: 'checksum2');
|
||||
|
||||
when(() => ctx.permissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf('asset1'), TrashSyncStatus.pending);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
|
||||
when(() => ctx.permissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
await sut.reconcile();
|
||||
expect(await trashStatusOf('asset1'), TrashSyncStatus.trashed);
|
||||
expect(await trashStatusOf('asset2'), TrashSyncStatus.trashed);
|
||||
});
|
||||
|
||||
test('iOS: reconcile maintains the list but never trashes', () async {
|
||||
debugDefaultTargetPlatformOverride = .iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = .android);
|
||||
await markAsset(localId: 'asset', checksum: 'checksum');
|
||||
|
||||
await sut.reconcile();
|
||||
|
||||
expect(await trashStatusOf('asset'), TrashSyncStatus.pending);
|
||||
verifyNever(() => ctx.assetMediaApi.trash(any()));
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<AssetMediaActionResult>> Function(Invocation) _mapStatus(AssetMediaActionStatus status) =>
|
||||
(inv) async => _ids(inv).map((id) => AssetMediaActionResult(id: id, status: status)).toList();
|
||||
|
||||
List<String> _ids(Invocation inv) => (inv.positionalArguments.first as List).cast<String>();
|
||||
@@ -25,6 +25,8 @@ class _FrozenBucketService implements TimelineService {
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
const _EmptyBucketService();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -10,7 +10,7 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
|
||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||
|
||||
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||
class MockPermissionRepository extends Mock implements PermissionRepository {}
|
||||
|
||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
@@ -24,7 +23,6 @@ void main() {
|
||||
late MockDriftLocalAssetRepository localAssetRepository;
|
||||
late MockDriftAlbumApiRepository albumApiRepository;
|
||||
late MockRemoteAlbumRepository remoteAlbumRepository;
|
||||
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
|
||||
late MockAssetMediaRepository assetMediaRepository;
|
||||
late MockDownloadRepository downloadRepository;
|
||||
late MockTagService tagService;
|
||||
@@ -51,7 +49,6 @@ void main() {
|
||||
localAssetRepository = MockDriftLocalAssetRepository();
|
||||
albumApiRepository = MockDriftAlbumApiRepository();
|
||||
remoteAlbumRepository = MockRemoteAlbumRepository();
|
||||
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
assetMediaRepository = MockAssetMediaRepository();
|
||||
downloadRepository = MockDownloadRepository();
|
||||
tagService = MockTagService();
|
||||
@@ -62,7 +59,6 @@ void main() {
|
||||
localAssetRepository,
|
||||
albumApiRepository,
|
||||
remoteAlbumRepository,
|
||||
trashedLocalAssetRepository,
|
||||
assetMediaRepository,
|
||||
downloadRepository,
|
||||
tagService,
|
||||
@@ -143,25 +139,9 @@ void main() {
|
||||
});
|
||||
|
||||
group('ActionService.deleteLocal', () {
|
||||
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
test('removes deleted ids from the local database', () async {
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
|
||||
test('deletes locally when Android trash handling is disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
@@ -170,11 +150,9 @@ void main() {
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
|
||||
test('short-circuits when nothing was deleted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['x'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
|
||||
@@ -183,7 +161,6 @@ void main() {
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,6 @@ import 'factories/user_factory.dart';
|
||||
class RepositoryMocks {
|
||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
|
||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||
|
||||
RepositoryMocks() {
|
||||
@@ -29,7 +27,6 @@ class RepositoryMocks {
|
||||
_registerFallbacks();
|
||||
localAlbum.reset();
|
||||
localAsset.reset();
|
||||
reset(trashedAsset);
|
||||
nativeApi.reset();
|
||||
_stubLocalAlbumRepository();
|
||||
_stubLocalAssetRepository();
|
||||
|
||||
@@ -17,7 +17,6 @@ void main() {
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -106,7 +105,6 @@ void main() {
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
|
||||
Reference in New Issue
Block a user