From 78ac0ade01d2052e9b0353ed64abf49061cc6327 Mon Sep 17 00:00:00 2001
From: Peter Ombodi
Date: Fri, 22 May 2026 15:10:11 +0300
Subject: [PATCH] feat(mobile): add manage media APIs to NativeSyncApi (#28441)
* feat(mobile): add manage media APIs to NativeSyncApi
* fix(mobile): remove legacy local file manager from trash sync
* refactor(mobile): move media permission methods to PermissionApi
* cleanup
---------
Co-authored-by: Peter Ombodi
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
---
.../app/alextran/immich/MainActivity.kt | 7 +
.../ManageMediaPermissionDelegate.kt | 96 +++++++++++++
.../immich/permission/PermissionApi.g.kt | 128 +++++++++++++++++
.../immich/permission/PermissionApiImpl.kt | 37 +++++
.../immich/sync/MediaTrashDelegate.kt | 133 ++++++++++++++++++
.../app/alextran/immich/sync/Messages.g.kt | 22 +++
.../alextran/immich/sync/MessagesImplBase.kt | 25 +++-
mobile/ios/Runner.xcodeproj/project.pbxproj | 16 +++
mobile/ios/Runner/AppDelegate.swift | 1 +
.../Runner/Permission/PermissionApi.g.swift | 106 ++++++++++++++
.../Runner/Permission/PermissionApiImpl.swift | 15 ++
mobile/ios/Runner/Sync/Messages.g.swift | 19 +++
mobile/ios/Runner/Sync/MessagesImpl.swift | 4 +
.../domain/services/local_sync.service.dart | 38 ++---
.../domain/services/sync_stream.service.dart | 40 +++---
mobile/lib/platform/native_sync_api.g.dart | 19 +++
mobile/lib/platform/permission_api.g.dart | 119 ++++++++++++++++
.../infrastructure/platform.provider.dart | 5 +-
.../infrastructure/sync.provider.dart | 12 +-
.../repositories/asset_media.repository.dart | 30 +++-
.../local_files_manager.repository.dart | 51 -------
.../repositories/permission.repository.dart | 28 +++-
.../services/local_files_manager.service.dart | 66 ---------
.../lib/widgets/forms/login/login_form.dart | 6 +-
.../widgets/settings/advanced_settings.dart | 10 +-
mobile/pigeon/native_sync_api.dart | 12 +-
mobile/pigeon/permission_api.dart | 23 +++
.../services/local_sync_service_test.dart | 72 ++++++----
.../services/sync_stream_service_test.dart | 99 ++++++++-----
mobile/test/repository.mocks.dart | 6 +-
30 files changed, 995 insertions(+), 250 deletions(-)
create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/permission/ManageMediaPermissionDelegate.kt
create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MediaTrashDelegate.kt
create mode 100644 mobile/ios/Runner/Permission/PermissionApi.g.swift
create mode 100644 mobile/ios/Runner/Permission/PermissionApiImpl.swift
create mode 100644 mobile/lib/platform/permission_api.g.dart
delete mode 100644 mobile/lib/repositories/local_files_manager.repository.dart
delete mode 100644 mobile/lib/services/local_files_manager.service.dart
create mode 100644 mobile/pigeon/permission_api.dart
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index 2c80b8d2b..b4cd705b0 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -17,6 +17,8 @@ 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.permission.PermissionApi
+import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -44,7 +46,9 @@ class MainActivity : FlutterFragmentActivity() {
} else {
NativeSyncApiImpl30(ctx)
}
+ val permissionApiImpl = PermissionApiImpl(ctx)
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
+ PermissionApi.setUp(messenger, permissionApiImpl)
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
@@ -53,6 +57,7 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
+ flutterEngine.plugins.add(permissionApiImpl)
}
fun cancelPlugins(flutterEngine: FlutterEngine) {
@@ -60,6 +65,8 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
nativeApi?.detachFromEngine()
+ val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
+ permissionApi?.detachFromEngine()
}
}
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/ManageMediaPermissionDelegate.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/ManageMediaPermissionDelegate.kt
new file mode 100644
index 000000000..ddabfbabd
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/ManageMediaPermissionDelegate.kt
@@ -0,0 +1,96 @@
+package app.alextran.immich.permission
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.provider.MediaStore
+import android.provider.Settings
+import androidx.core.net.toUri
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+import io.flutter.plugin.common.PluginRegistry
+
+class ManageMediaPermissionDelegate(
+ context: Context,
+ private val requestCode: Int = 1003,
+) : PluginRegistry.ActivityResultListener {
+ private val ctx = context.applicationContext
+ private var activityBinding: ActivityPluginBinding? = null
+ private var pendingResult: ((Result) -> Unit)? = null
+
+ fun hasManageMediaPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaStore.canManageMedia(ctx)
+ } else {
+ false
+ }
+ }
+
+ fun requestManageMediaPermission(callback: (Result) -> Unit) {
+ if (hasManageMediaPermission()) {
+ callback(Result.success(true))
+ return
+ }
+
+ openManageMediaPermissionSettings(callback)
+ }
+
+ fun manageMediaPermission(callback: (Result) -> Unit) {
+ openManageMediaPermissionSettings(callback)
+ }
+
+ private fun openManageMediaPermissionSettings(callback: (Result) -> Unit) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ callback(Result.success(false))
+ return
+ }
+
+ val activity = activityBinding?.activity
+ if (activity == null) {
+ callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
+ return
+ }
+
+ pendingResult = callback
+ val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
+ data = "package:${activity.packageName}".toUri()
+ }
+ try {
+ activity.startActivityForResult(intent, requestCode)
+ } catch (e: Exception) {
+ pendingResult = null
+ callback(
+ Result.failure(
+ FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
+ )
+ )
+ }
+ }
+
+ fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ binding.addActivityResultListener(this)
+ }
+
+ fun onDetachedFromActivity() {
+ failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
+ activityBinding?.removeActivityResultListener(this)
+ activityBinding = null
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ if (requestCode == this.requestCode) {
+ val callback = pendingResult
+ pendingResult = null
+ callback?.invoke(Result.success(hasManageMediaPermission()))
+ return true
+ }
+
+ return false
+ }
+
+ private fun failPending(code: String, message: String) {
+ val callback = pendingResult ?: return
+ pendingResult = null
+ callback(Result.failure(FlutterError(code, message, null)))
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
new file mode 100644
index 000000000..48a1a7203
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt
@@ -0,0 +1,128 @@
+// Autogenerated from Pigeon (v26.3.4), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
+
+package app.alextran.immich.permission
+
+import android.util.Log
+import io.flutter.plugin.common.BasicMessageChannel
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MessageCodec
+import io.flutter.plugin.common.StandardMethodCodec
+import io.flutter.plugin.common.StandardMessageCodec
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+private object PermissionApiPigeonUtils {
+
+ fun wrapResult(result: Any?): List {
+ return listOf(result)
+ }
+
+ fun wrapError(exception: Throwable): List {
+ return if (exception is FlutterError) {
+ listOf(
+ exception.code,
+ exception.message,
+ exception.details
+ )
+ } else {
+ listOf(
+ exception.javaClass.simpleName,
+ exception.toString(),
+ "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
+ )
+ }
+ }
+}
+
+/**
+ * Error class for passing custom error details to Flutter via a thrown PlatformException.
+ * @property code The error code.
+ * @property message The error message.
+ * @property details The error details. Must be a datatype supported by the api codec.
+ */
+class FlutterError (
+ val code: String,
+ override val message: String? = null,
+ val details: Any? = null
+) : RuntimeException()
+private open class PermissionApiPigeonCodec : StandardMessageCodec() {
+ override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
+ return super.readValueOfType(type, buffer)
+ }
+ override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
+ super.writeValue(stream, value)
+ }
+}
+
+
+/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
+interface PermissionApi {
+ fun hasManageMediaPermission(): Boolean
+ fun requestManageMediaPermission(callback: (Result) -> Unit)
+ fun manageMediaPermission(callback: (Result) -> Unit)
+
+ companion object {
+ /** The codec used by PermissionApi. */
+ val codec: MessageCodec by lazy {
+ PermissionApiPigeonCodec()
+ }
+ /** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
+ @JvmOverloads
+ fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.hasManageMediaPermission())
+ } catch (exception: Throwable) {
+ PermissionApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.requestManageMediaPermission{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(PermissionApiPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(PermissionApiPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.manageMediaPermission{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(PermissionApiPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(PermissionApiPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
new file mode 100644
index 000000000..c3443bb06
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApiImpl.kt
@@ -0,0 +1,37 @@
+package app.alextran.immich.permission
+
+import android.content.Context
+import app.alextran.immich.core.ImmichPlugin
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+
+class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
+ private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
+
+ override fun hasManageMediaPermission(): Boolean =
+ manageMediaPermissionDelegate.hasManageMediaPermission()
+
+ override fun requestManageMediaPermission(callback: (Result) -> Unit) {
+ manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
+ }
+
+ override fun manageMediaPermission(callback: (Result) -> Unit) {
+ manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
+ }
+
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ manageMediaPermissionDelegate.onAttachedToActivity(binding)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ manageMediaPermissionDelegate.onDetachedFromActivity()
+ }
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
+ manageMediaPermissionDelegate.onAttachedToActivity(binding)
+ }
+
+ override fun onDetachedFromActivity() {
+ manageMediaPermissionDelegate.onDetachedFromActivity()
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MediaTrashDelegate.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MediaTrashDelegate.kt
new file mode 100644
index 000000000..fbf4d7e91
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MediaTrashDelegate.kt
@@ -0,0 +1,133 @@
+package app.alextran.immich.sync
+
+import android.app.Activity
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.MediaStore
+import androidx.annotation.RequiresApi
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
+import io.flutter.plugin.common.PluginRegistry
+
+class MediaTrashDelegate(
+ context: Context,
+ private val trashRequestCode: Int = 1002,
+) : PluginRegistry.ActivityResultListener {
+ private val ctx = context.applicationContext
+ private var activityBinding: ActivityPluginBinding? = null
+ private var pendingResult: ((Result) -> 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) -> 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) -> 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)))
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
index b49664dea..345302026 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
@@ -553,6 +553,7 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map>
+ fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result) -> Unit)
fun getCloudIdForAssetIds(assetIds: List): List
companion object {
@@ -747,6 +748,27 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val mediaIdArg = args[0] as String
+ val typeArg = args[1] as Long
+ api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result ->
+ 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(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
index 777a565fe..1f5ff2529 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt
@@ -17,6 +17,8 @@ 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
@@ -39,10 +41,11 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
-open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
+open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
+ private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -448,6 +451,26 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask = null
}
+ fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result) -> 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): List {
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index e45914336..65cc5ec43 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -19,6 +19,8 @@
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
+ B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
+ B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -105,6 +107,8 @@
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = ""; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = ""; };
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = ""; };
+ B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = ""; };
+ B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = ""; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = ""; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -283,6 +287,7 @@
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
+ B2EE00052E72CA15008B6CA7 /* Permission */,
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -317,6 +322,15 @@
path = Connectivity;
sourceTree = "";
};
+ B2EE00052E72CA15008B6CA7 /* Permission */ = {
+ isa = PBXGroup;
+ children = (
+ B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
+ B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
+ );
+ path = Permission;
+ sourceTree = "";
+ };
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
isa = PBXGroup;
children = (
@@ -619,6 +633,8 @@
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
+ B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
+ B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index 216146a6f..e1ff3b1f6 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -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)!)
+ PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
diff --git a/mobile/ios/Runner/Permission/PermissionApi.g.swift b/mobile/ios/Runner/Permission/PermissionApi.g.swift
new file mode 100644
index 000000000..53ad9e5b1
--- /dev/null
+++ b/mobile/ios/Runner/Permission/PermissionApi.g.swift
@@ -0,0 +1,106 @@
+// Autogenerated from Pigeon (v26.3.4), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+import Foundation
+
+#if os(iOS)
+ import Flutter
+#elseif os(macOS)
+ import FlutterMacOS
+#else
+ #error("Unsupported platform.")
+#endif
+
+private func wrapResult(_ result: Any?) -> [Any?] {
+ return [result]
+}
+
+private func wrapError(_ error: Any) -> [Any?] {
+ if let pigeonError = error as? PigeonError {
+ return [
+ pigeonError.code,
+ pigeonError.message,
+ pigeonError.details,
+ ]
+ }
+ if let flutterError = error as? FlutterError {
+ return [
+ flutterError.code,
+ flutterError.message,
+ flutterError.details,
+ ]
+ }
+ return [
+ "\(error)",
+ "\(Swift.type(of: error))",
+ "Stacktrace: \(Thread.callStackSymbols)",
+ ]
+}
+
+private func isNullish(_ value: Any?) -> Bool {
+ return value is NSNull || value == nil
+}
+
+private func nilOrValue(_ value: Any?) -> T? {
+ if value is NSNull { return nil }
+ return value as! T?
+}
+
+/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
+protocol PermissionApi {
+ func hasManageMediaPermission() throws -> Bool
+ func requestManageMediaPermission(completion: @escaping (Result) -> Void)
+ func manageMediaPermission(completion: @escaping (Result) -> Void)
+}
+
+/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
+class PermissionApiSetup {
+ static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
+ /// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
+ static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
+ let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
+ let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ hasManageMediaPermissionChannel.setMessageHandler { _, reply in
+ do {
+ let result = try api.hasManageMediaPermission()
+ reply(wrapResult(result))
+ } catch {
+ reply(wrapError(error))
+ }
+ }
+ } else {
+ hasManageMediaPermissionChannel.setMessageHandler(nil)
+ }
+ let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ requestManageMediaPermissionChannel.setMessageHandler { _, reply in
+ api.requestManageMediaPermission { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
+ }
+ }
+ } else {
+ requestManageMediaPermissionChannel.setMessageHandler(nil)
+ }
+ let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
+ if let api = api {
+ manageMediaPermissionChannel.setMessageHandler { _, reply in
+ api.manageMediaPermission { result in
+ switch result {
+ case .success(let res):
+ reply(wrapResult(res))
+ case .failure(let error):
+ reply(wrapError(error))
+ }
+ }
+ }
+ } else {
+ manageMediaPermissionChannel.setMessageHandler(nil)
+ }
+ }
+}
diff --git a/mobile/ios/Runner/Permission/PermissionApiImpl.swift b/mobile/ios/Runner/Permission/PermissionApiImpl.swift
new file mode 100644
index 000000000..e725b742f
--- /dev/null
+++ b/mobile/ios/Runner/Permission/PermissionApiImpl.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+class PermissionApiImpl: PermissionApi {
+ func hasManageMediaPermission() throws -> Bool {
+ return false
+ }
+
+ func requestManageMediaPermission(completion: @escaping (Result) -> Void) {
+ completion(.success(false))
+ }
+
+ func manageMediaPermission(completion: @escaping (Result) -> Void) {
+ completion(.success(false))
+ }
+}
diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift
index 2933fc89a..d18a153bb 100644
--- a/mobile/ios/Runner/Sync/Messages.g.swift
+++ b/mobile/ios/Runner/Sync/Messages.g.swift
@@ -537,6 +537,7 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
+ func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
@@ -721,6 +722,24 @@ class NativeSyncApiSetup {
} 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)
diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift
index 40b71bd6c..e6903defe 100644
--- a/mobile/ios/Runner/Sync/MessagesImpl.swift
+++ b/mobile/ios/Runner/Sync/MessagesImpl.swift
@@ -382,6 +382,10 @@ 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) -> Void) {
+ completion(.success(false))
+ }
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult {
// Ensure to actually getting all assets for the Recents album
diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart
index 34300dee3..23f9e3f78 100644
--- a/mobile/lib/domain/services/local_sync.service.dart
+++ b/mobile/lib/domain/services/local_sync.service.dart
@@ -9,10 +9,10 @@ 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/storage.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/local_files_manager.repository.dart';
+import 'package:immich_mobile/repositories/asset_media.repository.dart';
+import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
@@ -23,29 +23,29 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
- final LocalFilesManagerRepository _localFilesManager;
- final StorageRepository _storageRepository;
+ final AssetMediaRepository _assetMediaRepository;
+ final IPermissionRepository _permissionRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
- required LocalFilesManagerRepository localFilesManager,
- required StorageRepository storageRepository,
+ required AssetMediaRepository assetMediaRepository,
+ required IPermissionRepository permissionRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
- _localFilesManager = localFilesManager,
- _storageRepository = storageRepository,
+ _assetMediaRepository = assetMediaRepository,
+ _permissionRepository = permissionRepository,
_nativeSyncApi = nativeSyncApi;
Future sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
- final hasPermission = await _localFilesManager.hasManageMediaPermission();
+ final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
@@ -373,7 +373,7 @@ class LocalSyncService {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
- final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
+ final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
@@ -381,15 +381,15 @@ class LocalSyncService {
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
- final mediaUrls = await Future.wait(
- localAssetsToTrash.values
- .expand((e) => e)
- .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
- );
- _log.info("Moving to trash ${mediaUrls.join(", ")} assets");
- final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
- if (result) {
- await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
+ 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");
diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart
index 9c8bac4c9..862d4c165 100644
--- a/mobile/lib/domain/services/sync_stream.service.dart
+++ b/mobile/lib/domain/services/sync_stream.service.dart
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
-import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_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/local_files_manager.repository.dart';
+import 'package:immich_mobile/repositories/asset_media.repository.dart';
+import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -34,8 +34,8 @@ class SyncStreamService {
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
- final LocalFilesManagerRepository _localFilesManager;
- final StorageRepository _storageRepository;
+ final AssetMediaRepository _assetMediaRepository;
+ final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
@@ -45,8 +45,8 @@ class SyncStreamService {
required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
- required LocalFilesManagerRepository localFilesManager,
- required StorageRepository storageRepository,
+ required AssetMediaRepository assetMediaRepository,
+ required IPermissionRepository permissionRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
@@ -54,8 +54,8 @@ class SyncStreamService {
_syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
- _localFilesManager = localFilesManager,
- _storageRepository = storageRepository,
+ _assetMediaRepository = assetMediaRepository,
+ _permissionRepository = permissionRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
@@ -500,22 +500,22 @@ class SyncStreamService {
}
Future _trashLocalAssets(Map> localAssetsToTrash) async {
- final mediaUrls = await Future.wait(
- localAssetsToTrash.values
- .expand((e) => e)
- .map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
- );
- _logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
- final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
- if (result) {
- await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
+ 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 _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
- final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
+ final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_logger.info("No remote assets found for restoration");
@@ -523,7 +523,7 @@ class SyncStreamService {
}
Future _syncAssetTrashStatus(List remoteIds) async {
- if (!(await _localFilesManager.hasManageMediaPermission())) {
+ if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
@@ -533,7 +533,7 @@ class SyncStreamService {
}
Future _syncAssetDeletion(List remoteIds) async {
- if (!(await _localFilesManager.hasManageMediaPermission())) {
+ if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart
index e7095663b..ff6ca7bf9 100644
--- a/mobile/lib/platform/native_sync_api.g.dart
+++ b/mobile/lib/platform/native_sync_api.g.dart
@@ -654,6 +654,25 @@ class NativeSyncApi {
return (pigeonVar_replyValue! as Map