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 2c80b8d2bd..b4cd705b05 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 0000000000..ddabfbabd8 --- /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 0000000000..48a1a72037 --- /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 0000000000..c3443bb06d --- /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 0000000000..fbf4d7e919 --- /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 b49664dea5..345302026d 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 777a565fe3..1f5ff2529e 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 e45914336b..65cc5ec433 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 216146a6f3..e1ff3b1f6e 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 0000000000..53ad9e5b11 --- /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 0000000000..e725b742fd --- /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 2933fc89af..d18a153bb7 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 40b71bd6c2..e6903defeb 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 34300dee3d..23f9e3f78d 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 9c8bac4c92..862d4c165c 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 e7095663b0..ff6ca7bf9d 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).cast>(); } + Future restoreFromTrashById(String mediaId, int type) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([mediaId, type]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + Future> getCloudIdForAssetIds(List assetIds) async { final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; diff --git a/mobile/lib/platform/permission_api.g.dart b/mobile/lib/platform/permission_api.g.dart new file mode 100644 index 0000000000..d2646e482f --- /dev/null +++ b/mobile/lib/platform/permission_api.g.dart @@ -0,0 +1,119 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow(List? replyList, String channelName, {required bool isNullValid}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class PermissionApi { + /// Constructor for [PermissionApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future hasManageMediaPermission() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future requestManageMediaPermission() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + Future manageMediaPermission() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } +} diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 01d0f61d1c..9f85235927 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/local_image_api.g.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; +import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/platform/remote_image_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); @@ -16,6 +17,8 @@ final backgroundWorkerLockServiceProvider = Provider((_) => NativeSyncApi()); +final permissionApiProvider = Provider((_) => PermissionApi()); + final connectivityApiProvider = Provider((_) => ConnectivityApi()); final localImageApi = LocalImageApi(); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 5b9f29225e..75c8e09326 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -11,8 +11,8 @@ 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/providers/infrastructure/storage.provider.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'; final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider))); @@ -22,8 +22,8 @@ final syncStreamServiceProvider = Provider( syncStreamRepository: ref.watch(syncStreamRepositoryProvider), localAssetRepository: ref.watch(localAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), - localFilesManager: ref.watch(localFilesManagerRepositoryProvider), - storageRepository: ref.watch(storageRepositoryProvider), + assetMediaRepository: ref.watch(assetMediaRepositoryProvider), + permissionRepository: ref.watch(permissionRepositoryProvider), syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider), api: ref.watch(apiServiceProvider), cancelChecker: ref.watch(cancellationProvider), @@ -39,8 +39,8 @@ final localSyncServiceProvider = Provider( localAlbumRepository: ref.watch(localAlbumRepository), localAssetRepository: ref.watch(localAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), - localFilesManager: ref.watch(localFilesManagerRepositoryProvider), - storageRepository: ref.watch(storageRepositoryProvider), + assetMediaRepository: ref.watch(assetMediaRepositoryProvider), + permissionRepository: ref.watch(permissionRepositoryProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider), ), ); diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index a2d8bfe162..6b34d1855f 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -8,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/response_extensions.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:share_plus/share_plus.dart'; -final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider))); +final assetMediaRepositoryProvider = Provider( + (ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)), +); class AssetMediaRepository { final AssetApiRepository _assetApiRepository; + final NativeSyncApi _nativeSyncApi; static final Logger _log = Logger("AssetMediaRepository"); - const AssetMediaRepository(this._assetApiRepository); + const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi); Future _androidSupportsTrash() async { if (Platform.isAndroid) { @@ -45,6 +50,27 @@ class AssetMediaRepository { return PhotoManager.editor.deleteWithIds(ids); } + Future _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> restoreAssetsFromTrash(Iterable assets) async { + final restoredIds = []; + 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 get(String id) async { final entity = await AssetEntity.fromId(id); return entity; diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart deleted file mode 100644 index 6a6200b2e1..0000000000 --- a/mobile/lib/repositories/local_files_manager.repository.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/services/local_files_manager.service.dart'; -import 'package:logging/logging.dart'; - -final localFilesManagerRepositoryProvider = Provider( - (ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)), -); - -class LocalFilesManagerRepository { - LocalFilesManagerRepository(this._service); - - final Logger _logger = Logger('LocalFilesManagerRepo'); - final LocalFilesManagerService _service; - - Future moveToTrash(List mediaUrls) async { - return await _service.moveToTrash(mediaUrls); - } - - Future restoreFromTrash(String fileName, int type) async { - return await _service.restoreFromTrash(fileName, type); - } - - Future requestManageMediaPermission() async { - return await _service.requestManageMediaPermission(); - } - - Future hasManageMediaPermission() async { - return await _service.hasManageMediaPermission(); - } - - Future manageMediaPermission() async { - return await _service.manageMediaPermission(); - } - - Future> restoreAssetsFromTrash(Iterable assets) async { - final restoredIds = []; - for (final asset in assets) { - _logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}"); - try { - final result = await _service.restoreFromTrashById(asset.id, asset.type.index); - if (result) { - restoredIds.add(asset.id); - } - } catch (e) { - _logger.warning("Restoring failure: $e"); - } - } - return restoredIds; - } -} diff --git a/mobile/lib/repositories/permission.repository.dart b/mobile/lib/repositories/permission.repository.dart index 74230a3652..e4b6d99f25 100644 --- a/mobile/lib/repositories/permission.repository.dart +++ b/mobile/lib/repositories/permission.repository.dart @@ -1,12 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/platform/permission_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:permission_handler/permission_handler.dart'; -final permissionRepositoryProvider = Provider((_) { - return const PermissionRepository(); +final permissionRepositoryProvider = Provider((ref) { + return PermissionRepository(ref.watch(permissionApiProvider)); }); class PermissionRepository implements IPermissionRepository { - const PermissionRepository(); + final PermissionApi _permissionApi; + + const PermissionRepository(this._permissionApi); @override Future hasLocationWhenInUsePermission() { @@ -34,6 +38,21 @@ class PermissionRepository implements IPermissionRepository { Future openSettings() { return openAppSettings(); } + + @override + Future hasManageMediaPermission() { + return _permissionApi.hasManageMediaPermission(); + } + + @override + Future requestManageMediaPermission() { + return _permissionApi.requestManageMediaPermission(); + } + + @override + Future manageMediaPermission() { + return _permissionApi.manageMediaPermission(); + } } abstract interface class IPermissionRepository { @@ -42,4 +61,7 @@ abstract interface class IPermissionRepository { Future hasLocationAlwaysPermission(); Future requestLocationAlwaysPermission(); Future openSettings(); + Future hasManageMediaPermission(); + Future requestManageMediaPermission(); + Future manageMediaPermission(); } diff --git a/mobile/lib/services/local_files_manager.service.dart b/mobile/lib/services/local_files_manager.service.dart deleted file mode 100644 index 0cc00f3e4b..0000000000 --- a/mobile/lib/services/local_files_manager.service.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:logging/logging.dart'; - -final localFileManagerServiceProvider = Provider((ref) => const LocalFilesManagerService()); - -class LocalFilesManagerService { - const LocalFilesManagerService(); - - static final Logger _logger = Logger('LocalFilesManager'); - static const MethodChannel _channel = MethodChannel('file_trash'); - - Future moveToTrash(List mediaUrls) async { - try { - return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls}); - } catch (e, s) { - _logger.warning('Error moving file to trash', e, s); - return false; - } - } - - Future restoreFromTrash(String fileName, int type) async { - try { - return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type}); - } catch (e, s) { - _logger.warning('Error restore file from trash', e, s); - return false; - } - } - - Future restoreFromTrashById(String mediaId, int type) async { - try { - return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type}); - } catch (e, s) { - _logger.warning('Error restore file from trash by Id', e, s); - return false; - } - } - - Future requestManageMediaPermission() async { - try { - return await _channel.invokeMethod('requestManageMediaPermission'); - } catch (e, s) { - _logger.warning('Error requesting manage media permission', e, s); - return false; - } - } - - Future hasManageMediaPermission() async { - try { - return await _channel.invokeMethod('hasManageMediaPermission'); - } catch (e, s) { - _logger.warning('Error requesting manage media permission state', e, s); - return false; - } - } - - Future manageMediaPermission() async { - try { - return await _channel.invokeMethod('manageMediaPermission'); - } catch (e, s) { - _logger.warning('Error requesting manage media permission settings', e, s); - return false; - } - } -} diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 4967f28718..f64e7cc197 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/url_helper.dart'; @@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget { } getManageMediaPermission() async { - final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission(); + final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission(); if (!hasPermission) { await showDialog( context: context, @@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget { ), TextButton( onPressed: () { - ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); + unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission()); Navigator.of(context).pop(); }, child: Text( diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 60557aaaca..5de2570737 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; @@ -57,9 +57,7 @@ class AdvancedSettings extends HookConsumerWidget { () async { isManageMediaSupported.value = await checkAndroidVersion(); if (isManageMediaSupported.value) { - manageMediaAndroidPermission.value = await ref - .read(localFilesManagerRepositoryProvider) - .hasManageMediaPermission(); + manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission(); } }(); return null; @@ -82,7 +80,7 @@ class AdvancedSettings extends HookConsumerWidget { subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(), onChanged: (value) async { if (value) { - final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); + final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission(); manageLocalMediaAndroid.value = result; manageMediaAndroidPermission.value = result; } @@ -96,7 +94,7 @@ class AdvancedSettings extends HookConsumerWidget { ? const Color.fromARGB(255, 243, 188, 106) : null, onActionTap: () async { - final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission(); + final result = await ref.read(permissionRepositoryProvider).manageMediaPermission(); manageMediaAndroidPermission.value = result; }, ), diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index e215ab9447..9775973694 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart'; dartPackageName: 'immich_mobile', ), ) -enum PlatformAssetPlaybackStyle { - unknown, - image, - video, - imageAnimated, - livePhoto, - videoLooping, -} +enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } class PlatformAsset { final String id; @@ -142,6 +135,9 @@ abstract class NativeSyncApi { @TaskQueue(type: TaskQueueType.serialBackgroundThread) Map> getTrashedAssets(); + @async + bool restoreFromTrashById(String mediaId, int type); + @TaskQueue(type: TaskQueueType.serialBackgroundThread) List getCloudIdForAssetIds(List assetIds); } diff --git a/mobile/pigeon/permission_api.dart b/mobile/pigeon/permission_api.dart new file mode 100644 index 0000000000..a924f32e27 --- /dev/null +++ b/mobile/pigeon/permission_api.dart @@ -0,0 +1,23 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/permission_api.g.dart', + swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift', + swiftOptions: SwiftOptions(), + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class PermissionApi { + bool hasManageMediaPermission(); + + @async + bool requestManageMediaPermission(); + + @async + bool manageMediaPermission(); +} diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index c15bf9b2d0..e0e6b663a7 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -10,17 +10,15 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/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/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/local_files_manager.repository.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 '../../mocks/asset_entity.mock.dart'; import '../../repository.mocks.dart'; void main() { @@ -28,8 +26,8 @@ void main() { late DriftLocalAlbumRepository mockLocalAlbumRepository; late DriftLocalAssetRepository mockLocalAssetRepository; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; - late LocalFilesManagerRepository mockLocalFilesManager; - late StorageRepository mockStorageRepository; + late AssetMediaRepository mockAssetMediaRepository; + late MockPermissionRepository mockPermissionRepository; late MockNativeSyncApi mockNativeSyncApi; late Drift db; @@ -51,8 +49,8 @@ void main() { mockLocalAlbumRepository = MockLocalAlbumRepository(); mockLocalAssetRepository = MockLocalAssetRepository(); mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); - mockLocalFilesManager = MockLocalFilesManagerRepository(); - mockStorageRepository = MockStorageRepository(); + mockAssetMediaRepository = MockAssetMediaRepository(); + mockPermissionRepository = MockPermissionRepository(); mockNativeSyncApi = MockNativeSyncApi(); when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false); @@ -65,25 +63,28 @@ void main() { when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {}); when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {}); when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {}); - when(() => mockLocalFilesManager.moveToTrash(any>())).thenAnswer((_) async => true); + when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async { + final ids = invocation.positionalArguments.first as List; + return ids; + }); sut = LocalSyncService( localAlbumRepository: mockLocalAlbumRepository, localAssetRepository: mockLocalAssetRepository, trashedLocalAssetRepository: mockTrashedLocalAssetRepository, - localFilesManager: mockLocalFilesManager, - storageRepository: mockStorageRepository, + assetMediaRepository: mockAssetMediaRepository, + permissionRepository: mockPermissionRepository, nativeSyncApi: mockNativeSyncApi, ); await Store.put(StoreKey.manageLocalMediaAndroid, false); - when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => 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(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); + when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true); await sut.sync(); @@ -93,7 +94,7 @@ void main() { test('skips syncTrashedAssets when store flag disabled', () async { await Store.put(StoreKey.manageLocalMediaAndroid, false); - when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); + when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true); await sut.sync(); @@ -102,7 +103,7 @@ void main() { test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async { await Store.put(StoreKey.manageLocalMediaAndroid, true); - when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false); + when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false); await sut.sync(); @@ -114,7 +115,7 @@ void main() { addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); await Store.put(StoreKey.manageLocalMediaAndroid, true); - when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); + when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true); await sut.sync(); @@ -131,13 +132,13 @@ void main() { durationMs: 0, orientation: 0, isFavorite: false, - playbackStyle: PlatformAssetPlaybackStyle.image + playbackStyle: PlatformAssetPlaybackStyle.image, ); final assetsToRestore = [LocalAssetStub.image1]; when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore); final restoredIds = ['image1']; - when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { + when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { final Iterable requested = invocation.positionalArguments.first as Iterable; expect(requested, orderedEquals(assetsToRestore)); return restoredIds; @@ -150,10 +151,6 @@ void main() { }, ); - final assetEntity = MockAssetEntity(); - when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash'); - when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity); - await sut.processTrashedAssets({ 'album-a': [platformAsset], }); @@ -168,12 +165,11 @@ void main() { expect(trashedEntry.asset.name, platformAsset.name); verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1); - verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1); + verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1); verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1); - verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1); - final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List; - expect(moveArgs, ['content://local-trash']); + final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List; + expect(moveArgs, ['local-trash']); final trashArgs = verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single as Map>; @@ -181,6 +177,26 @@ void main() { 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>; + 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 => []); @@ -190,7 +206,7 @@ void main() { verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single as Iterable; expect(trashedSnapshot, isEmpty); - verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any())); + verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())); verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())); }); @@ -199,7 +215,7 @@ void main() { await sut.processTrashedAssets({}); - verifyNever(() => mockLocalFilesManager.moveToTrash(any())); + verifyNever(() => mockAssetMediaRepository.deleteAll(any())); verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())); }); }); @@ -215,7 +231,7 @@ void main() { isFavorite: false, createdAt: 1700000000, updatedAt: 1732000000, - playbackStyle: PlatformAssetPlaybackStyle.image + playbackStyle: PlatformAssetPlaybackStyle.image, ); final localAsset = platformAsset.toLocalAsset(); diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 1bee1dccde..ef29997e0b 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -12,12 +12,11 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/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/local_files_manager.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'; @@ -26,7 +25,6 @@ import '../../api.mocks.dart'; import '../../fixtures/asset.stub.dart'; import '../../fixtures/sync_stream.stub.dart'; import '../../infrastructure/repository.mock.dart'; -import '../../mocks/asset_entity.mock.dart'; import '../../repository.mocks.dart'; import '../../service.mocks.dart'; @@ -52,8 +50,8 @@ void main() { late SyncApiRepository mockSyncApiRepo; late DriftLocalAssetRepository mockLocalAssetRepo; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo; - late LocalFilesManagerRepository mockLocalFilesManagerRepo; - late StorageRepository mockStorageRepo; + late AssetMediaRepository mockAssetMediaRepo; + late MockPermissionRepository mockPermissionRepo; late MockApiService mockApi; late MockServerApi mockServerApi; late MockSyncMigrationRepository mockSyncMigrationRepo; @@ -86,8 +84,8 @@ void main() { mockSyncApiRepo = MockSyncApiRepository(); mockLocalAssetRepo = MockLocalAssetRepository(); mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository(); - mockLocalFilesManagerRepo = MockLocalFilesManagerRepository(); - mockStorageRepo = MockStorageRepository(); + mockAssetMediaRepo = MockAssetMediaRepository(); + mockPermissionRepo = MockPermissionRepository(); mockAbortCallbackWrapper = _MockAbortCallbackWrapper(); mockResetCallbackWrapper = _MockAbortCallbackWrapper(); mockApi = MockApiService(); @@ -159,8 +157,8 @@ void main() { syncStreamRepository: mockSyncStreamRepo, localAssetRepository: mockLocalAssetRepo, trashedLocalAssetRepository: mockTrashedLocalAssetRepo, - localFilesManager: mockLocalFilesManagerRepo, - storageRepository: mockStorageRepo, + assetMediaRepository: mockAssetMediaRepo, + permissionRepository: mockPermissionRepo, api: mockApi, syncMigrationRepository: mockSyncMigrationRepo, ); @@ -170,10 +168,12 @@ void main() { when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []); when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {}); hasManageMediaPermission = false; - when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission); - when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true); - when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []); - when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null); + when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission); + when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async { + final ids = invocation.positionalArguments.first as List; + return ids; + }); + when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []); await Store.put(StoreKey.manageLocalMediaAndroid, false); }); @@ -241,8 +241,8 @@ void main() { syncStreamRepository: mockSyncStreamRepo, localAssetRepository: mockLocalAssetRepo, trashedLocalAssetRepository: mockTrashedLocalAssetRepo, - localFilesManager: mockLocalFilesManagerRepo, - storageRepository: mockStorageRepo, + assetMediaRepository: mockAssetMediaRepo, + permissionRepository: mockPermissionRepo, cancelChecker: cancellationChecker.call, api: mockApi, syncMigrationRepository: mockSyncMigrationRepo, @@ -282,8 +282,8 @@ void main() { syncStreamRepository: mockSyncStreamRepo, localAssetRepository: mockLocalAssetRepo, trashedLocalAssetRepository: mockTrashedLocalAssetRepo, - localFilesManager: mockLocalFilesManagerRepo, - storageRepository: mockStorageRepo, + assetMediaRepository: mockAssetMediaRepo, + permissionRepository: mockPermissionRepo, cancelChecker: cancellationChecker.call, api: mockApi, syncMigrationRepository: mockSyncMigrationRepo, @@ -424,18 +424,10 @@ void main() { return assetsByAlbum; }); - final localEntity = MockAssetEntity(); - when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only'); - when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity); - - final mergedEntity = MockAssetEntity(); - when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local'); - when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity); - - when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async { - final urls = invocation.positionalArguments.first as List; - expect(urls, unorderedEquals(['content://local-only', 'content://merged-local'])); - return true; + when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async { + final ids = invocation.positionalArguments.first as List; + expect(ids, unorderedEquals(['local-only', 'merged-local'])); + return ids; }); final events = [ @@ -461,10 +453,51 @@ void main() { await simulateEvents(events); - verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1); + final trashArgs = + verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single + as Map>; + 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>; + 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( @@ -478,7 +511,7 @@ void main() { await simulateEvents(events); verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); - verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); + verifyNever(() => mockAssetMediaRepo.deleteAll(any())); verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())); }); @@ -494,7 +527,7 @@ void main() { await simulateEvents(events); verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); - verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); + verifyNever(() => mockAssetMediaRepo.deleteAll(any())); verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1); }); @@ -505,7 +538,7 @@ void main() { when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets); final restoredIds = ['trashed-1']; - when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { + when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { final Iterable requestedAssets = invocation.positionalArguments.first as Iterable; expect(requestedAssets, orderedEquals(trashedAssets)); return restoredIds; diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 4b27541246..80786420fd 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -3,17 +3,17 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; -import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:mocktail/mocktail.dart'; class MockAssetApiRepository extends Mock implements AssetApiRepository {} class MockAssetMediaRepository extends Mock implements AssetMediaRepository {} +class MockPermissionRepository extends Mock implements IPermissionRepository {} + class MockAuthApiRepository extends Mock implements AuthApiRepository {} class MockAuthRepository extends Mock implements AuthRepository {} -class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {} - class MockTagService extends Mock implements TagService {}