Compare commits

...

1 Commits

Author SHA1 Message Date
shenlong-tanwen
ff7d92b380 refactor: auto trash / restore sync 2026-07-15 06:20:48 +05:30
75 changed files with 17658 additions and 2709 deletions

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View 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)
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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)))
}
}

View File

@@ -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) {

View File

@@ -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()
}
}

View File

@@ -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() }
}
}

View File

@@ -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> {

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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()
}
}

View 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)
}
}
}

View 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([]))
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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,
}

View File

@@ -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)),

View File

@@ -37,6 +37,9 @@ enum SettingsKey<T> {
albumIsReverse<bool>(),
albumIsGrid<bool>(),
// Sync
trashSyncEnabled<bool>(),
// Backup
backupEnabled<bool>(),
backupUseCellularForVideos<bool>(),

View File

@@ -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),

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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);
}
}

View 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();
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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)',

View File

@@ -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";
}

View 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();
}
}

View 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";
}

View 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();
}
}

View File

@@ -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

View File

@@ -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)]);

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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,
),
);

View File

@@ -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 {

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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
View 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>();
}
}

View File

@@ -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';

View File

@@ -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");
}

View File

@@ -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),

View File

@@ -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());

View File

@@ -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),
),
);

View File

@@ -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),
),
);

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()]));

View File

@@ -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();
},
),
],

View File

@@ -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'),
);
},
),
],
],
);
},

View 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);
}

View File

@@ -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);
}

View File

@@ -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 {}

View File

@@ -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()));
});
});

View File

@@ -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, "[]");

View File

@@ -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,
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 {}

View File

@@ -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);

View 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);
});
});
}

View File

@@ -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)),

View File

@@ -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 => []);
}

View File

@@ -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);
});

View 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>();

View File

@@ -25,6 +25,8 @@ class _FrozenBucketService implements TimelineService {
}
class _EmptyBucketService implements TimelineService {
const _EmptyBucketService();
@override
Stream<List<Bucket>> Function() get watchBuckets =>
() => Stream.value(const []);

View File

@@ -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 {}

View File

@@ -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()));
});
});

View File

@@ -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();

View File

@@ -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();