From ef80a8e9365b3fa7cf5196848f55d3651f3a488c Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 5 Feb 2026 18:54:59 +0200 Subject: [PATCH 01/14] feat(mobile): handle Android ACTION_VIEW intent - add ViewIntent Pigeon API and generated bindings - implement Android ViewIntentPlugin + iOS no-op host - route ExternalMediaViewer by ViewIntentAttachment - buffer pending view intents and flush on user ready/resume --- .../android/app/src/main/AndroidManifest.xml | 16 ++ .../app/alextran/immich/MainActivity.kt | 82 +++--- .../immich/viewintent/ViewIntent.g.kt | 193 +++++++++++++ .../immich/viewintent/ViewIntentPlugin.kt | 264 ++++++++++++++++++ mobile/ios/Runner/AppDelegate.swift | 1 + .../ios/Runner/ViewIntent/ViewIntent.g.swift | 228 +++++++++++++++ .../Runner/ViewIntent/ViewIntentApiImpl.swift | 5 + mobile/lib/main.dart | 81 +++--- .../view_intent_attachment.model.dart | 21 ++ mobile/lib/platform/view_intent_api.g.dart | 147 ++++++++++ .../pages/external_media_viewer.page.dart | 39 +++ .../view_intent_handler.provider.dart | 93 ++++++ .../repositories/view_handler.repository.dart | 28 ++ mobile/lib/routing/router.dart | 3 + mobile/lib/routing/router.gr.dart | 41 +++ mobile/lib/services/view_intent_service.dart | 39 +++ mobile/makefile | 2 + mobile/pigeon/view_intent_api.dart | 34 +++ mobile/pubspec.lock | 8 +- 19 files changed, 1251 insertions(+), 74 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt create mode 100644 mobile/ios/Runner/ViewIntent/ViewIntent.g.swift create mode 100644 mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift create mode 100644 mobile/lib/models/view_intent/view_intent_attachment.model.dart create mode 100644 mobile/lib/platform/view_intent_api.g.dart create mode 100644 mobile/lib/presentation/pages/external_media_viewer.page.dart create mode 100644 mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart create mode 100644 mobile/lib/repositories/view_handler.repository.dart create mode 100644 mobile/lib/services/view_intent_service.dart create mode 100644 mobile/pigeon/view_intent_api.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 0d4925077a653..39f0002f8e654 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -92,6 +92,22 @@ + + + + + + + + + + + + + + + + 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 08790d97725e2..c6edcf73f35c1 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 @@ -1,34 +1,41 @@ -package app.alextran.immich - -import android.content.Context -import android.os.Build -import android.os.ext.SdkExtensions -import app.alextran.immich.background.BackgroundEngineLock -import app.alextran.immich.background.BackgroundWorkerApiImpl -import app.alextran.immich.background.BackgroundWorkerFgHostApi -import app.alextran.immich.background.BackgroundWorkerLockApi -import app.alextran.immich.connectivity.ConnectivityApi -import app.alextran.immich.connectivity.ConnectivityApiImpl -import app.alextran.immich.core.ImmichPlugin -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.sync.NativeSyncApi -import app.alextran.immich.sync.NativeSyncApiImpl26 -import app.alextran.immich.sync.NativeSyncApiImpl30 -import io.flutter.embedding.android.FlutterFragmentActivity -import io.flutter.embedding.engine.FlutterEngine - -class MainActivity : FlutterFragmentActivity() { - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - registerPlugins(this, flutterEngine) - } - - companion object { - fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { - val messenger = flutterEngine.dartExecutor.binaryMessenger +package app.alextran.immich + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.ext.SdkExtensions +import app.alextran.immich.background.BackgroundEngineLock +import app.alextran.immich.background.BackgroundWorkerApiImpl +import app.alextran.immich.background.BackgroundWorkerFgHostApi +import app.alextran.immich.background.BackgroundWorkerLockApi +import app.alextran.immich.connectivity.ConnectivityApi +import app.alextran.immich.connectivity.ConnectivityApiImpl +import app.alextran.immich.core.ImmichPlugin +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.sync.NativeSyncApi +import app.alextran.immich.sync.NativeSyncApiImpl26 +import app.alextran.immich.sync.NativeSyncApiImpl30 +import app.alextran.immich.viewintent.ViewIntentPlugin +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity : FlutterFragmentActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + registerPlugins(this, flutterEngine) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } + + companion object { + fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { + val messenger = flutterEngine.dartExecutor.binaryMessenger val backgroundEngineLockImpl = BackgroundEngineLock(ctx) BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl) val nativeSyncApiImpl = @@ -43,12 +50,13 @@ class MainActivity : FlutterFragmentActivity() { BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) - - flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) - flutterEngine.plugins.add(backgroundEngineLockImpl) - flutterEngine.plugins.add(nativeSyncApiImpl) - } + + flutterEngine.plugins.add(BackgroundServicePlugin()) + flutterEngine.plugins.add(HttpSSLOptionsPlugin()) + flutterEngine.plugins.add(ViewIntentPlugin()) + flutterEngine.plugins.add(backgroundEngineLockImpl) + flutterEngine.plugins.add(nativeSyncApiImpl) + } fun cancelPlugins(flutterEngine: FlutterEngine) { val nativeApi = diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt new file mode 100644 index 0000000000000..d06732ad9397d --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt @@ -0,0 +1,193 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.viewintent + +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 ViewIntentPigeonUtils { + + 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) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** + * 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 +) : Throwable() + +enum class ViewIntentType(val raw: Int) { + IMAGE(0), + VIDEO(1); + + companion object { + fun ofRaw(raw: Int): ViewIntentType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ViewIntentPayload ( + val path: String, + val type: ViewIntentType, + val mimeType: String, + val localAssetId: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ViewIntentPayload { + val path = pigeonVar_list[0] as String + val type = pigeonVar_list[1] as ViewIntentType + val mimeType = pigeonVar_list[2] as String + val localAssetId = pigeonVar_list[3] as String? + return ViewIntentPayload(path, type, mimeType, localAssetId) + } + } + fun toList(): List { + return listOf( + path, + type, + mimeType, + localAssetId, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ViewIntentPayload) { + return false + } + if (this === other) { + return true + } + return ViewIntentPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class ViewIntentPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + ViewIntentType.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + ViewIntentPayload.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is ViewIntentType -> { + stream.write(129) + writeValue(stream, value.raw) + } + is ViewIntentPayload -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface ViewIntentHostApi { + fun consumeViewIntent(callback: (Result) -> Unit) + + companion object { + /** The codec used by ViewIntentHostApi. */ + val codec: MessageCodec by lazy { + ViewIntentPigeonCodec() + } + /** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.consumeViewIntent{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(ViewIntentPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(ViewIntentPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt new file mode 100644 index 0000000000000..5407e782b2eb4 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -0,0 +1,264 @@ +package app.alextran.immich.viewintent + +import android.app.Activity +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.PluginRegistry +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi { + private var context: Context? = null + private var activity: Activity? = null + private var pendingIntent: Intent? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + ViewIntentHostApi.setUp(binding.binaryMessenger, this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + ViewIntentHostApi.setUp(binding.binaryMessenger, null) + context = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + pendingIntent = binding.activity.intent + binding.addOnNewIntentListener(this) + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun onNewIntent(intent: Intent): Boolean { + pendingIntent = intent + return false + } + + override fun consumeViewIntent(callback: (Result) -> Unit) { + val context = context ?: run { + callback(Result.success(null)) + return + } + val intent = pendingIntent ?: activity?.intent + + if (intent?.action != Intent.ACTION_VIEW) { + callback(Result.success(null)) + return + } + + val uri = intent.data + if (uri == null) { + callback(Result.success(null)) + return + } + + val mimeType = context.contentResolver.getType(uri) + if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) { + callback(Result.success(null)) + return + } + + try { + val tempFile = copyUriToTempFile(context, uri, mimeType) + if (tempFile == null) { + callback(Result.success(null)) + return + } + + val payload = ViewIntentPayload( + path = tempFile.absolutePath, + type = if (mimeType.startsWith("image/")) ViewIntentType.IMAGE else ViewIntentType.VIDEO, + mimeType = mimeType, + localAssetId = extractLocalAssetId(context, uri, mimeType), + ) + consumeViewIntent(intent) + callback(Result.success(payload)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + + private fun consumeViewIntent(currentIntent: Intent) { + pendingIntent = Intent(currentIntent).apply { + action = null + data = null + type = null + } + activity?.intent = pendingIntent + } + + private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? { + if (uri.scheme != "content") { + return null + } + + try { + if (DocumentsContract.isDocumentUri(context, uri)) { + val docId = DocumentsContract.getDocumentId(uri) + if (docId.startsWith("raw:")) { + return null + } + + if (docId.isNotBlank()) { + val parsed = docId.substringAfter(':', docId) + if (parsed.all(Char::isDigit)) { + return parsed + } + val fromRelativePath = resolveLocalIdByRelativePath(context, parsed, mimeType) + if (fromRelativePath != null) { + return fromRelativePath + } + } + } + } catch (_: Exception) { + // Ignore and continue with fallback strategy. + } + + try { + val parsed = ContentUris.parseId(uri) + if (parsed >= 0) { + return parsed.toString() + } + } catch (_: Exception) { + // Ignore and continue with fallback strategy. + } + + val segment = uri.lastPathSegment + if (segment != null && segment.all(Char::isDigit)) { + return segment + } + + return resolveLocalIdByNameAndSize(context, uri, mimeType) + } + + private fun resolveLocalIdByRelativePath(context: Context, path: String, mimeType: String): String? { + val fileName = path.substringAfterLast('/', missingDelimiterValue = path) + val parent = path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" } + if (fileName.isBlank()) return null + + val tableUri = when { + mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + else -> MediaStore.Files.getContentUri("external") + } + + val projection = arrayOf(MediaStore.MediaColumns._ID) + val (selection, args) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?" to arrayOf(fileName, parent) + } else { + "${MediaStore.MediaColumns.DISPLAY_NAME}=?" to arrayOf(fileName) + } + + return try { + context.contentResolver + .query(tableUri, projection, selection, args, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC") + ?.use { cursor -> + if (!cursor.moveToFirst()) return null + val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + if (idIndex < 0) return null + cursor.getLong(idIndex).toString() + } + } catch (_: Exception) { + null + } + } + + private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? { + val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + val (displayName, size) = + try { + context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) return null + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null + val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L + if (name.isNullOrBlank() || bytes < 0) return null + name to bytes + } ?: return null + } catch (_: Exception) { + return null + } + + val tableUri = when { + mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + else -> MediaStore.Files.getContentUri("external") + } + val projection = arrayOf(MediaStore.MediaColumns._ID) + val selection = "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?" + val args = arrayOf(displayName, size.toString()) + + return try { + context.contentResolver + .query(tableUri, projection, selection, args, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC") + ?.use { cursor -> + if (!cursor.moveToFirst()) return null + val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + if (idIndex < 0) return null + cursor.getLong(idIndex).toString() + } + } catch (_: Exception) { + null + } + } + + private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? { + return try { + val inputStream: InputStream? = context.contentResolver.openInputStream(uri) + if (inputStream == null) return null + + val extension = when { + mimeType.startsWith("image/") -> { + when { + mimeType.contains("jpeg") || mimeType.contains("jpg") -> ".jpg" + mimeType.contains("png") -> ".png" + mimeType.contains("gif") -> ".gif" + mimeType.contains("webp") -> ".webp" + else -> ".jpg" + } + } + mimeType.startsWith("video/") -> { + when { + mimeType.contains("mp4") -> ".mp4" + mimeType.contains("webm") -> ".webm" + mimeType.contains("3gp") -> ".3gp" + else -> ".mp4" + } + } + else -> ".tmp" + } + + val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir) + val outputStream = FileOutputStream(tempFile) + inputStream.copyTo(outputStream) + inputStream.close() + outputStream.close() + tempFile + } catch (_: Exception) { + null + } + } +} diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 60f97b6645cc6..3224ad40c7f4f 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -57,6 +57,7 @@ import UIKit RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl()) + ViewIntentHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ViewIntentApiImpl()) } public static func cancelPlugins(with engine: FlutterEngine) { diff --git a/mobile/ios/Runner/ViewIntent/ViewIntent.g.swift b/mobile/ios/Runner/ViewIntent/ViewIntent.g.swift new file mode 100644 index 0000000000000..b28332976c210 --- /dev/null +++ b/mobile/ios/Runner/ViewIntent/ViewIntent.g.swift @@ -0,0 +1,228 @@ +// Autogenerated from Pigeon (v26.0.2), 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)", + "\(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? +} + +func deepEqualsViewIntent(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsViewIntent(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsViewIntent(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashViewIntent(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashViewIntent(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashViewIntent(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +enum ViewIntentType: Int { + case image = 0 + case video = 1 +} + +/// Generated class from Pigeon that represents data sent in messages. +struct ViewIntentPayload: Hashable { + var path: String + var type: ViewIntentType + var mimeType: String + var localAssetId: String? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ViewIntentPayload? { + let path = pigeonVar_list[0] as! String + let type = pigeonVar_list[1] as! ViewIntentType + let mimeType = pigeonVar_list[2] as! String + let localAssetId: String? = nilOrValue(pigeonVar_list[3]) + + return ViewIntentPayload( + path: path, + type: type, + mimeType: mimeType, + localAssetId: localAssetId + ) + } + func toList() -> [Any?] { + return [ + path, + type, + mimeType, + localAssetId, + ] + } + static func == (lhs: ViewIntentPayload, rhs: ViewIntentPayload) -> Bool { + return deepEqualsViewIntent(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashViewIntent(value: toList(), hasher: &hasher) + } +} + +private class ViewIntentPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return ViewIntentType(rawValue: enumResultAsInt) + } + return nil + case 130: + return ViewIntentPayload.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class ViewIntentPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? ViewIntentType { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? ViewIntentPayload { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class ViewIntentPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return ViewIntentPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return ViewIntentPigeonCodecWriter(data: data) + } +} + +class ViewIntentPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = ViewIntentPigeonCodec(readerWriter: ViewIntentPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol ViewIntentHostApi { + func consumeViewIntent(completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class ViewIntentHostApiSetup { + static var codec: FlutterStandardMessageCodec { ViewIntentPigeonCodec.shared } + /// Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let consumeViewIntentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + consumeViewIntentChannel.setMessageHandler { _, reply in + api.consumeViewIntent { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + consumeViewIntentChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift b/mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift new file mode 100644 index 0000000000000..07ff78a582ac2 --- /dev/null +++ b/mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift @@ -0,0 +1,5 @@ +class ViewIntentApiImpl: ViewIntentHostApi { + func consumeViewIntent(completion: @escaping (Result) -> Void) { + completion(.success(nil)) + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 60bb1cb9c3744..6ea282cdc307e 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -11,25 +11,28 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/domain/services/background_worker.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/intl_keys.g.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; -import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/view_intent_handler.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/providers/locale_provider.dart'; -import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/providers/theme.provider.dart'; -import 'package:immich_mobile/routing/app_navigation_observer.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart'; @@ -130,14 +133,19 @@ class ImmichApp extends ConsumerStatefulWidget { ImmichAppState createState() => ImmichAppState(); } -class ImmichAppState extends ConsumerState with WidgetsBindingObserver { - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - dPrint(() => "[APP STATE] resumed"); - ref.read(appStateProvider.notifier).handleAppResume(); - break; +class ImmichAppState extends ConsumerState with WidgetsBindingObserver { + ProviderSubscription? _userSubscription; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + dPrint(() => "[APP STATE] resumed"); + ref.read(appStateProvider.notifier).handleAppResume(); + // Check for ACTION_VIEW intent when app resumes + unawaited(ref.read(viewIntentServiceProvider).checkViewIntent()); + unawaited(ref.read(viewIntentServiceProvider).flushPending()); + break; case AppLifecycleState.inactive: dPrint(() => "[APP STATE] inactive"); ref.read(appStateProvider.notifier).handleAppInactivity(); @@ -206,9 +214,9 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve }); } - @override - initState() { - super.initState(); + @override + initState() { + super.initState(); initApp().then((_) => dPrint(() => "App Init Completed")); WidgetsBinding.instance.addPostFrameCallback((_) { // needs to be delayed so that EasyLocalization is working @@ -228,15 +236,22 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); } }); - - ref.read(shareIntentUploadProvider.notifier).init(); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } + + ref.read(viewIntentHandlerProvider).init(); + ref.read(shareIntentUploadProvider.notifier).init(); + _userSubscription = ref.listenManual(currentUserProvider, (_, user) { + if (user != null) { + unawaited(ref.read(viewIntentServiceProvider).flushPending()); + } + }, fireImmediately: true); + } + + @override + void dispose() { + _userSubscription?.close(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } @override void reassemble() { diff --git a/mobile/lib/models/view_intent/view_intent_attachment.model.dart b/mobile/lib/models/view_intent/view_intent_attachment.model.dart new file mode 100644 index 0000000000000..92f2b2f88cf21 --- /dev/null +++ b/mobile/lib/models/view_intent/view_intent_attachment.model.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:path/path.dart'; + +enum ViewIntentAttachmentType { image, video } + +class ViewIntentAttachment { + final String path; + final ViewIntentAttachmentType type; + final String? localAssetId; + + const ViewIntentAttachment({required this.path, required this.type, this.localAssetId}); + + File get file => File(path); + + String get fileName => basename(file.path); + + bool get isImage => type == ViewIntentAttachmentType.image; + + bool get isVideo => type == ViewIntentAttachmentType.video; +} diff --git a/mobile/lib/platform/view_intent_api.g.dart b/mobile/lib/platform/view_intent_api.g.dart new file mode 100644 index 0000000000000..bcc1f3a8eef2f --- /dev/null +++ b/mobile/lib/platform/view_intent_api.g.dart @@ -0,0 +1,147 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), + ); + } + return a == b; +} + +enum ViewIntentType { image, video } + +class ViewIntentPayload { + ViewIntentPayload({required this.path, required this.type, required this.mimeType, this.localAssetId}); + + String path; + + ViewIntentType type; + + String mimeType; + + String? localAssetId; + + List _toList() { + return [path, type, mimeType, localAssetId]; + } + + Object encode() { + return _toList(); + } + + static ViewIntentPayload decode(Object result) { + result as List; + return ViewIntentPayload( + path: result[0]! as String, + type: result[1]! as ViewIntentType, + mimeType: result[2]! as String, + localAssetId: result[3] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ViewIntentPayload || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is ViewIntentType) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is ViewIntentPayload) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : ViewIntentType.values[value]; + case 130: + return ViewIntentPayload.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class ViewIntentHostApi { + /// Constructor for [ViewIntentHostApi]. 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. + ViewIntentHostApi({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 consumeViewIntent() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as ViewIntentPayload?); + } + } +} diff --git a/mobile/lib/presentation/pages/external_media_viewer.page.dart b/mobile/lib/presentation/pages/external_media_viewer.page.dart new file mode 100644 index 0000000000000..bfebfc76ab330 --- /dev/null +++ b/mobile/lib/presentation/pages/external_media_viewer.page.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +@RoutePage() +class ExternalMediaViewerPage extends StatelessWidget { + const ExternalMediaViewerPage({super.key, required this.attachment}); + + final ViewIntentAttachment attachment; + + @override + Widget build(BuildContext context) { + final file = File(attachment.path); + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text(attachment.fileName, overflow: TextOverflow.ellipsis), + ), + body: Center( + child: attachment.isImage + ? PhotoView( + index: 0, + imageProvider: FileImage(file), + backgroundDecoration: const BoxDecoration(color: Colors.black), + ) + : AspectRatio( + aspectRatio: 16 / 9, + child: Center(child: Icon(Icons.videocam, size: 64, color: Colors.white.withValues(alpha: 0.8))), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart new file mode 100644 index 0000000000000..7b5104980c6cf --- /dev/null +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/view_intent_service.dart'; + +final viewIntentHandlerProvider = Provider( + (ref) => ViewIntentHandler( + ref, + ref.read(viewIntentServiceProvider), + ref.watch(appRouterProvider), + ref.read(localAssetRepository), + ref.read(timelineFactoryProvider), + ), +); + +class ViewIntentHandler { + final Ref _ref; + final ViewIntentService _viewIntentService; + final AppRouter _router; + final DriftLocalAssetRepository _localAssetRepository; + final TimelineFactory _timelineFactory; + + const ViewIntentHandler( + this._ref, + this._viewIntentService, + this._router, + this._localAssetRepository, + this._timelineFactory, + ); + + void init() { + _viewIntentService.onViewMedia = onViewMedia; + unawaited(_viewIntentService.checkViewIntent()); + unawaited(_viewIntentService.flushPending()); + } + + Future onViewMedia(List attachments) async { + if (attachments.isEmpty) { + return; + } + await handle(attachments.first); + } + + Future handle(ViewIntentAttachment attachment) async { + if (_ref.read(currentUserProvider) == null) { + _viewIntentService.defer(attachment); + return; + } + + final localAssetId = attachment.localAssetId; + if (localAssetId != null) { + final localAsset = await _localAssetRepository.getById(localAssetId); + if (localAsset != null) { + _openAssetViewer(localAsset); + return; + } + } + + await _router.push(ExternalMediaViewerRoute(attachment: attachment)); + } + + void _openAssetViewer(LocalAsset asset) { + _ref.read(assetViewerProvider.notifier).reset(); + _ref.read(assetViewerProvider.notifier).setAsset(asset); + _ref.read(currentAssetNotifier.notifier).setAsset(asset); + if (asset.isVideo || asset.isMotionPhoto) { + _ref.read(videoPlaybackValueProvider.notifier).reset(); + _ref.read(videoPlayerControlsProvider.notifier).pause(); + } + if (asset.isVideo) { + _ref.read(assetViewerProvider.notifier).setControls(false); + } + + _router.push( + AssetViewerRoute( + initialIndex: 0, + timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink), + ), + ); + } +} diff --git a/mobile/lib/repositories/view_handler.repository.dart b/mobile/lib/repositories/view_handler.repository.dart new file mode 100644 index 0000000000000..1feeb40f78647 --- /dev/null +++ b/mobile/lib/repositories/view_handler.repository.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; + +final viewHandlerRepositoryProvider = Provider((ref) => ViewHandlerRepository(ViewIntentHostApi())); + +class ViewHandlerRepository { + final ViewIntentHostApi _viewIntentHostApi; + + const ViewHandlerRepository(this._viewIntentHostApi); + + Future checkViewIntent() async { + try { + final result = await _viewIntentHostApi.consumeViewIntent(); + if (result == null) { + return null; + } + return ViewIntentAttachment( + path: result.path, + type: result.type == ViewIntentType.image ? ViewIntentAttachmentType.image : ViewIntentAttachmentType.video, + localAssetId: result.localAssetId, + ); + } catch (_) { + // Ignore errors - view intent might not be present + return null; + } + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9468b105e5e3e..bfc66464f20bd 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; import 'package:immich_mobile/pages/album/album_options.page.dart'; @@ -104,6 +105,7 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; +import 'package:immich_mobile/presentation/pages/external_media_viewer.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart'; @@ -340,6 +342,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: ExternalMediaViewerRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b287d73114392..cfb7654005465 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1653,6 +1653,47 @@ class EditImageRouteArgs { } } +/// generated route for +/// [ExternalMediaViewerPage] +class ExternalMediaViewerRoute + extends PageRouteInfo { + ExternalMediaViewerRoute({ + Key? key, + required ViewIntentAttachment attachment, + List? children, + }) : super( + ExternalMediaViewerRoute.name, + args: ExternalMediaViewerRouteArgs(key: key, attachment: attachment), + initialChildren: children, + ); + + static const String name = 'ExternalMediaViewerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return ExternalMediaViewerPage( + key: args.key, + attachment: args.attachment, + ); + }, + ); +} + +class ExternalMediaViewerRouteArgs { + const ExternalMediaViewerRouteArgs({this.key, required this.attachment}); + + final Key? key; + + final ViewIntentAttachment attachment; + + @override + String toString() { + return 'ExternalMediaViewerRouteArgs{key: $key, attachment: $attachment}'; + } +} + /// generated route for /// [FailedBackupStatusPage] class FailedBackupStatusRoute extends PageRouteInfo { diff --git a/mobile/lib/services/view_intent_service.dart b/mobile/lib/services/view_intent_service.dart new file mode 100644 index 0000000000000..c3d322b83f821 --- /dev/null +++ b/mobile/lib/services/view_intent_service.dart @@ -0,0 +1,39 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; +import 'package:immich_mobile/repositories/view_handler.repository.dart'; + +final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ref.watch(viewHandlerRepositoryProvider))); + +class ViewIntentService { + final ViewHandlerRepository _viewHandlerRepository; + Future Function(List attachments)? onViewMedia; + ViewIntentAttachment? _pendingAttachment; + + ViewIntentService(this._viewHandlerRepository); + + Future checkViewIntent() async { + final attachment = await _viewHandlerRepository.checkViewIntent(); + if (attachment != null) { + final handler = onViewMedia; + if (handler == null) { + _pendingAttachment = attachment; + return; + } + await handler([attachment]); + } + } + + void defer(ViewIntentAttachment attachment) { + _pendingAttachment = attachment; + } + + Future flushPending() async { + final pendingAttachment = _pendingAttachment; + final handler = onViewMedia; + if (pendingAttachment == null || handler == null) { + return; + } + _pendingAttachment = null; + await handler([pendingAttachment]); + } +} diff --git a/mobile/makefile b/mobile/makefile index 79b263c079a19..f4b7a770224dc 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -12,12 +12,14 @@ pigeon: dart run pigeon --input pigeon/background_worker_api.dart dart run pigeon --input pigeon/background_worker_lock_api.dart dart run pigeon --input pigeon/connectivity_api.dart + dart run pigeon --input pigeon/view_intent_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/local_image_api.g.dart dart format lib/platform/remote_image_api.g.dart dart format lib/platform/background_worker_api.g.dart dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart + dart format lib/platform/view_intent_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/pigeon/view_intent_api.dart b/mobile/pigeon/view_intent_api.dart new file mode 100644 index 0000000000000..1e4f1515ed934 --- /dev/null +++ b/mobile/pigeon/view_intent_api.dart @@ -0,0 +1,34 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/view_intent_api.g.dart', + swiftOut: 'ios/Runner/ViewIntent/ViewIntent.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.viewintent'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +enum ViewIntentType { image, video } + +class ViewIntentPayload { + final String path; + final ViewIntentType type; + final String mimeType; + final String? localAssetId; + + const ViewIntentPayload({ + required this.path, + required this.type, + required this.mimeType, + this.localAssetId, + }); +} + +@HostApi() +abstract class ViewIntentHostApi { + @async + ViewIntentPayload? consumeViewIntent(); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index c8aa680e07de6..d237c02023464 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1249,10 +1249,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1942,10 +1942,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" thumbhash: dependency: "direct main" description: From bb803f13dafcc010e389583f67e35ed3f26730ba Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 6 Feb 2026 18:09:07 +0200 Subject: [PATCH 02/14] feat(mobile): fallback to computed checksum for timeline match - hash local asset on-demand when checksum missing - search main timeline by localId or checksum before standalone viewer - persist computed hash into local_asset_entity --- .../view_intent_handler.provider.dart | 78 +++++++++++++++++-- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index 7b5104980c6cf..92056c039dac3 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -1,15 +1,18 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -21,6 +24,7 @@ final viewIntentHandlerProvider = Provider( ref.read(viewIntentServiceProvider), ref.watch(appRouterProvider), ref.read(localAssetRepository), + ref.read(nativeSyncApiProvider), ref.read(timelineFactoryProvider), ), ); @@ -30,6 +34,7 @@ class ViewIntentHandler { final ViewIntentService _viewIntentService; final AppRouter _router; final DriftLocalAssetRepository _localAssetRepository; + final NativeSyncApi _nativeSyncApi; final TimelineFactory _timelineFactory; const ViewIntentHandler( @@ -37,6 +42,7 @@ class ViewIntentHandler { this._viewIntentService, this._router, this._localAssetRepository, + this._nativeSyncApi, this._timelineFactory, ); @@ -63,7 +69,19 @@ class ViewIntentHandler { if (localAssetId != null) { final localAsset = await _localAssetRepository.getById(localAssetId); if (localAsset != null) { - _openAssetViewer(localAsset); + var checksum = localAsset.checksum; + if (checksum == null) { + checksum = await _computeChecksum(localAssetId); + if (checksum != null) { + await _localAssetRepository.updateHashes({localAssetId: checksum}); + } + } + //todo clarify logic for assets not presented into MainTimeline (locked folder, deleted etc) + final timelineMatch = await _openFromMainTimeline(localAssetId, checksum: checksum); + if (timelineMatch) { + return; + } + _openAssetViewer(localAsset, _timelineFactory.fromAssets([localAsset], TimelineOrigin.deepLink), 0); return; } } @@ -71,7 +89,44 @@ class ViewIntentHandler { await _router.push(ExternalMediaViewerRoute(attachment: attachment)); } - void _openAssetViewer(LocalAsset asset) { + Future _openFromMainTimeline(String localAssetId, {String? checksum}) async { + final timelineService = _ref.read(timelineServiceProvider); + if (timelineService.totalAssets == 0) { + try { + await timelineService.watchBuckets().first.timeout(const Duration(seconds: 2)); + } catch (_) { + // Ignore and fallback. + } + } + + final totalAssets = timelineService.totalAssets; + if (totalAssets == 0) { + return false; + } + + final batchSize = kTimelineAssetLoadBatchSize; + for (var offset = 0; offset < totalAssets; offset += batchSize) { + final count = (offset + batchSize > totalAssets) ? totalAssets - offset : batchSize; + final assets = await timelineService.loadAssets(offset, count); + final indexInBatch = assets.indexWhere((asset) { + if (asset.localId == localAssetId) { + return true; + } + if (checksum != null && asset.checksum == checksum) { + return true; + } + return false; + }); + if (indexInBatch >= 0) { + final asset = assets[indexInBatch]; + _openAssetViewer(asset, timelineService, offset + indexInBatch); + return true; + } + } + return false; + } + + void _openAssetViewer(BaseAsset asset, TimelineService timelineService, int initialIndex) { _ref.read(assetViewerProvider.notifier).reset(); _ref.read(assetViewerProvider.notifier).setAsset(asset); _ref.read(currentAssetNotifier.notifier).setAsset(asset); @@ -83,11 +138,18 @@ class ViewIntentHandler { _ref.read(assetViewerProvider.notifier).setControls(false); } - _router.push( - AssetViewerRoute( - initialIndex: 0, - timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink), - ), - ); + _router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService)); + } + + Future _computeChecksum(String localAssetId) async { + try { + final hashResults = await _nativeSyncApi.hashAssets([localAssetId]); + if (hashResults.isEmpty) { + return null; + } + return hashResults.first.hash; + } catch (_) { + return null; + } } } From 3ab68a4bf81082337b31bbc3d1d7dc026203b4b0 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Tue, 10 Feb 2026 15:49:22 +0200 Subject: [PATCH 03/14] fix(mobile): proper handling is user authenticated --- mobile/lib/main.dart | 12 ++++++------ .../asset_viewer/view_intent_handler.provider.dart | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 80c86d2553b4b..a0b920d803d55 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -24,14 +24,14 @@ import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/view_intent_handler.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/services/view_intent_service.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -134,7 +134,7 @@ class ImmichApp extends ConsumerStatefulWidget { } class ImmichAppState extends ConsumerState with WidgetsBindingObserver { - ProviderSubscription? _userSubscription; + ProviderSubscription? _authSubscription; @override void didChangeAppLifecycleState(AppLifecycleState state) { @@ -239,8 +239,8 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ref.read(viewIntentHandlerProvider).init(); ref.read(shareIntentUploadProvider.notifier).init(); - _userSubscription = ref.listenManual(currentUserProvider, (_, user) { - if (user != null) { + _authSubscription = ref.listenManual(authProvider.select((state) => state.isAuthenticated), (_, isAuthenticated) { + if (isAuthenticated) { unawaited(ref.read(viewIntentServiceProvider).flushPending()); } }, fireImmediately: true); @@ -248,7 +248,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve @override void dispose() { - _userSubscription?.close(); + _authSubscription?.close(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index 92056c039dac3..d0482785b4fa4 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -10,11 +10,11 @@ import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/view_intent_service.dart'; @@ -60,7 +60,7 @@ class ViewIntentHandler { } Future handle(ViewIntentAttachment attachment) async { - if (_ref.read(currentUserProvider) == null) { + if (!_ref.read(authProvider).isAuthenticated) { _viewIntentService.defer(attachment); return; } From bc301a3aac0d68422c52ddd32c10f4bcf6e2fe81 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Tue, 10 Feb 2026 16:35:27 +0200 Subject: [PATCH 04/14] feat(mobile): open ACTION_VIEW fallback in AssetViewer drop ExternalMediaViewer route --- .../pages/external_media_viewer.page.dart | 39 ------------------ .../view_intent_handler.provider.dart | 18 +++++++- mobile/lib/routing/router.dart | 3 -- mobile/lib/routing/router.gr.dart | 41 ------------------- 4 files changed, 16 insertions(+), 85 deletions(-) delete mode 100644 mobile/lib/presentation/pages/external_media_viewer.page.dart diff --git a/mobile/lib/presentation/pages/external_media_viewer.page.dart b/mobile/lib/presentation/pages/external_media_viewer.page.dart deleted file mode 100644 index bfebfc76ab330..0000000000000 --- a/mobile/lib/presentation/pages/external_media_viewer.page.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:io'; - -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; -import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; - -@RoutePage() -class ExternalMediaViewerPage extends StatelessWidget { - const ExternalMediaViewerPage({super.key, required this.attachment}); - - final ViewIntentAttachment attachment; - - @override - Widget build(BuildContext context) { - final file = File(attachment.path); - - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - title: Text(attachment.fileName, overflow: TextOverflow.ellipsis), - ), - body: Center( - child: attachment.isImage - ? PhotoView( - index: 0, - imageProvider: FileImage(file), - backgroundDecoration: const BoxDecoration(color: Colors.black), - ) - : AspectRatio( - aspectRatio: 16 / 9, - child: Center(child: Icon(Icons.videocam, size: 64, color: Colors.white.withValues(alpha: 0.8))), - ), - ), - ); - } -} diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index d0482785b4fa4..6ad98a57103cb 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -76,7 +76,6 @@ class ViewIntentHandler { await _localAssetRepository.updateHashes({localAssetId: checksum}); } } - //todo clarify logic for assets not presented into MainTimeline (locked folder, deleted etc) final timelineMatch = await _openFromMainTimeline(localAssetId, checksum: checksum); if (timelineMatch) { return; @@ -86,7 +85,8 @@ class ViewIntentHandler { } } - await _router.push(ExternalMediaViewerRoute(attachment: attachment)); + final fallbackAsset = _toViewIntentAsset(attachment); + _openAssetViewer(fallbackAsset, _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), 0); } Future _openFromMainTimeline(String localAssetId, {String? checksum}) async { @@ -152,4 +152,18 @@ class ViewIntentHandler { return null; } } + + LocalAsset _toViewIntentAsset(ViewIntentAttachment attachment) { + final now = DateTime.now(); + + return LocalAsset( + id: attachment.path, + name: attachment.fileName, + checksum: null, + type: attachment.isVideo ? AssetType.video : AssetType.image, + createdAt: now, + updatedAt: now, + isEdited: false, + ); + } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index bfc66464f20bd..9468b105e5e3e 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -16,7 +16,6 @@ import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; import 'package:immich_mobile/pages/album/album_options.page.dart'; @@ -105,7 +104,6 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; -import 'package:immich_mobile/presentation/pages/external_media_viewer.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart'; @@ -342,7 +340,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: ExternalMediaViewerRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index cfb7654005465..b287d73114392 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1653,47 +1653,6 @@ class EditImageRouteArgs { } } -/// generated route for -/// [ExternalMediaViewerPage] -class ExternalMediaViewerRoute - extends PageRouteInfo { - ExternalMediaViewerRoute({ - Key? key, - required ViewIntentAttachment attachment, - List? children, - }) : super( - ExternalMediaViewerRoute.name, - args: ExternalMediaViewerRouteArgs(key: key, attachment: attachment), - initialChildren: children, - ); - - static const String name = 'ExternalMediaViewerRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return ExternalMediaViewerPage( - key: args.key, - attachment: args.attachment, - ); - }, - ); -} - -class ExternalMediaViewerRouteArgs { - const ExternalMediaViewerRouteArgs({this.key, required this.attachment}); - - final Key? key; - - final ViewIntentAttachment attachment; - - @override - String toString() { - return 'ExternalMediaViewerRouteArgs{key: $key, attachment: $attachment}'; - } -} - /// generated route for /// [FailedBackupStatusPage] class FailedBackupStatusRoute extends PageRouteInfo { From c35c948f637dbf512ea7211ce05e71712d056d25 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Tue, 10 Feb 2026 18:25:02 +0200 Subject: [PATCH 05/14] feat(mobile): add logger --- .../asset_viewer/view_intent_handler.provider.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index 6ad98a57103cb..e4bace87631d2 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:logging/logging.dart'; final viewIntentHandlerProvider = Provider( (ref) => ViewIntentHandler( @@ -36,6 +37,7 @@ class ViewIntentHandler { final DriftLocalAssetRepository _localAssetRepository; final NativeSyncApi _nativeSyncApi; final TimelineFactory _timelineFactory; + static final Logger _logger = Logger('ViewIntentHandler'); const ViewIntentHandler( this._ref, @@ -60,12 +62,14 @@ class ViewIntentHandler { } Future handle(ViewIntentAttachment attachment) async { + _logger.info('handle attachment: $attachment'); if (!_ref.read(authProvider).isAuthenticated) { _viewIntentService.defer(attachment); return; } final localAssetId = attachment.localAssetId; + _logger.fine('localAssetId: $localAssetId'); if (localAssetId != null) { final localAsset = await _localAssetRepository.getById(localAssetId); if (localAsset != null) { @@ -77,6 +81,7 @@ class ViewIntentHandler { } } final timelineMatch = await _openFromMainTimeline(localAssetId, checksum: checksum); + _logger.fine('localAsset: $localAsset, checksum: $checksum, timelineMatch: $timelineMatch'); if (timelineMatch) { return; } @@ -86,6 +91,7 @@ class ViewIntentHandler { } final fallbackAsset = _toViewIntentAsset(attachment); + _logger.fine('openAssetViewer for fallbackAsset'); _openAssetViewer(fallbackAsset, _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), 0); } From fb66f53410776ab9ddee7e8afba89ac35c49632b Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Tue, 10 Feb 2026 18:33:30 +0200 Subject: [PATCH 06/14] test(mobile): add unit tests for view intent pending/flush flow --- .../services/view_intent_service_test.dart | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 mobile/test/services/view_intent_service_test.dart diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart new file mode 100644 index 0000000000000..fa4a940695296 --- /dev/null +++ b/mobile/test/services/view_intent_service_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; +import 'package:immich_mobile/repositories/view_handler.repository.dart'; +import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockViewHandlerRepository extends Mock implements ViewHandlerRepository {} + +void main() { + late MockViewHandlerRepository repository; + late ViewIntentService service; + + const attachment = ViewIntentAttachment( + path: '/tmp/file.jpg', + type: ViewIntentAttachmentType.image, + localAssetId: '42', + ); + + setUp(() { + repository = MockViewHandlerRepository(); + service = ViewIntentService(repository); + }); + + test('checkViewIntent does nothing when no attachment', () async { + when(() => repository.checkViewIntent()).thenAnswer((_) async => null); + + var called = 0; + service.onViewMedia = (_) async { + called++; + }; + + await service.checkViewIntent(); + + expect(called, 0); + verify(() => repository.checkViewIntent()).called(1); + }); + + test('checkViewIntent calls handler immediately when handler is set', () async { + when(() => repository.checkViewIntent()).thenAnswer((_) async => attachment); + + List? received; + service.onViewMedia = (attachments) async { + received = attachments; + }; + + await service.checkViewIntent(); + + expect(received, isNotNull); + expect(received, hasLength(1)); + expect(received!.first, attachment); + verify(() => repository.checkViewIntent()).called(1); + }); + + test('checkViewIntent stores pending attachment when handler is not set', () async { + when(() => repository.checkViewIntent()).thenAnswer((_) async => attachment); + + await service.checkViewIntent(); + + List? received; + service.onViewMedia = (attachments) async { + received = attachments; + }; + await service.flushPending(); + + expect(received, isNotNull); + expect(received, hasLength(1)); + expect(received!.first, attachment); + }); + + test('defer + flushPending does nothing without handler, then flushes once when handler appears', () async { + service.defer(attachment); + + // No handler yet: should keep pending. + await service.flushPending(); + + var called = 0; + List? received; + service.onViewMedia = (attachments) async { + called++; + received = attachments; + }; + + await service.flushPending(); + await service.flushPending(); + + expect(called, 1); + expect(received, hasLength(1)); + expect(received!.first, attachment); + }); +} From 175f8d99de3368dae13c3d11a6ddec300a1cea0a Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 12 Feb 2026 14:00:10 +0200 Subject: [PATCH 07/14] fix(mobile): fix format --- mobile/lib/main.dart | 98 ++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index a0b920d803d55..58f3f6637c124 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -11,32 +11,32 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/constants/locales.dart'; -import 'package:immich_mobile/domain/services/background_worker.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/intl_keys.g.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; -import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/view_intent_handler.provider.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/view_intent_service.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/providers/locale_provider.dart'; -import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/providers/theme.provider.dart'; -import 'package:immich_mobile/routing/app_navigation_observer.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/view_intent_handler.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; +import 'package:immich_mobile/services/view_intent_service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; @@ -133,19 +133,19 @@ class ImmichApp extends ConsumerStatefulWidget { ImmichAppState createState() => ImmichAppState(); } -class ImmichAppState extends ConsumerState with WidgetsBindingObserver { - ProviderSubscription? _authSubscription; - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - dPrint(() => "[APP STATE] resumed"); - ref.read(appStateProvider.notifier).handleAppResume(); - // Check for ACTION_VIEW intent when app resumes - unawaited(ref.read(viewIntentServiceProvider).checkViewIntent()); - unawaited(ref.read(viewIntentServiceProvider).flushPending()); - break; +class ImmichAppState extends ConsumerState with WidgetsBindingObserver { + ProviderSubscription? _authSubscription; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + dPrint(() => "[APP STATE] resumed"); + ref.read(appStateProvider.notifier).handleAppResume(); + // Check for ACTION_VIEW intent when app resumes + unawaited(ref.read(viewIntentServiceProvider).checkViewIntent()); + unawaited(ref.read(viewIntentServiceProvider).flushPending()); + break; case AppLifecycleState.inactive: dPrint(() => "[APP STATE] inactive"); ref.read(appStateProvider.notifier).handleAppInactivity(); @@ -214,9 +214,9 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve }); } - @override - initState() { - super.initState(); + @override + initState() { + super.initState(); initApp().then((_) => dPrint(() => "App Init Completed")); WidgetsBinding.instance.addPostFrameCallback((_) { // needs to be delayed so that EasyLocalization is working @@ -236,22 +236,22 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); } }); - - ref.read(viewIntentHandlerProvider).init(); - ref.read(shareIntentUploadProvider.notifier).init(); - _authSubscription = ref.listenManual(authProvider.select((state) => state.isAuthenticated), (_, isAuthenticated) { - if (isAuthenticated) { - unawaited(ref.read(viewIntentServiceProvider).flushPending()); - } - }, fireImmediately: true); - } - - @override - void dispose() { - _authSubscription?.close(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } + + ref.read(viewIntentHandlerProvider).init(); + ref.read(shareIntentUploadProvider.notifier).init(); + _authSubscription = ref.listenManual(authProvider.select((state) => state.isAuthenticated), (_, isAuthenticated) { + if (isAuthenticated) { + unawaited(ref.read(viewIntentServiceProvider).flushPending()); + } + }, fireImmediately: true); + } + + @override + void dispose() { + _authSubscription?.close(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } @override void reassemble() { From 4806dc76aa7ea775d76a9d7ae24cdd632791997d Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Wed, 15 Apr 2026 16:42:44 +0300 Subject: [PATCH 08/14] fix(mobile): remove redundant iOS code update code related to LocalAsset model and asset viewer --- mobile/ios/Runner/AppDelegate.swift | 1 - .../ios/Runner/ViewIntent/ViewIntent.g.swift | 228 ------------------ .../Runner/ViewIntent/ViewIntentApiImpl.swift | 5 - .../view_intent_attachment.model.dart | 27 ++- mobile/lib/platform/view_intent_api.g.dart | 58 +++-- .../view_intent_handler.provider.dart | 20 +- .../repositories/view_handler.repository.dart | 1 + mobile/pigeon/view_intent_api.dart | 2 - .../services/view_intent_service_test.dart | 1 + 9 files changed, 71 insertions(+), 272 deletions(-) delete mode 100644 mobile/ios/Runner/ViewIntent/ViewIntent.g.swift delete mode 100644 mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 218c0b2832819..81af41ab08f45 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -61,7 +61,6 @@ import UIKit BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl()) NetworkApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: NetworkApiImpl(viewController: controller)) - ViewIntentHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ViewIntentApiImpl()) } public static func cancelPlugins(with engine: FlutterEngine) { diff --git a/mobile/ios/Runner/ViewIntent/ViewIntent.g.swift b/mobile/ios/Runner/ViewIntent/ViewIntent.g.swift deleted file mode 100644 index b28332976c210..0000000000000 --- a/mobile/ios/Runner/ViewIntent/ViewIntent.g.swift +++ /dev/null @@ -1,228 +0,0 @@ -// Autogenerated from Pigeon (v26.0.2), 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)", - "\(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? -} - -func deepEqualsViewIntent(_ lhs: Any?, _ rhs: Any?) -> Bool { - let cleanLhs = nilOrValue(lhs) as Any? - let cleanRhs = nilOrValue(rhs) as Any? - switch (cleanLhs, cleanRhs) { - case (nil, nil): - return true - - case (nil, _), (_, nil): - return false - - case is (Void, Void): - return true - - case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): - return cleanLhsHashable == cleanRhsHashable - - case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): - guard cleanLhsArray.count == cleanRhsArray.count else { return false } - for (index, element) in cleanLhsArray.enumerated() { - if !deepEqualsViewIntent(element, cleanRhsArray[index]) { - return false - } - } - return true - - case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): - guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } - for (key, cleanLhsValue) in cleanLhsDictionary { - guard cleanRhsDictionary.index(forKey: key) != nil else { return false } - if !deepEqualsViewIntent(cleanLhsValue, cleanRhsDictionary[key]!) { - return false - } - } - return true - - default: - // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. - return false - } -} - -func deepHashViewIntent(value: Any?, hasher: inout Hasher) { - if let valueList = value as? [AnyHashable] { - for item in valueList { deepHashViewIntent(value: item, hasher: &hasher) } - return - } - - if let valueDict = value as? [AnyHashable: AnyHashable] { - for key in valueDict.keys { - hasher.combine(key) - deepHashViewIntent(value: valueDict[key]!, hasher: &hasher) - } - return - } - - if let hashableValue = value as? AnyHashable { - hasher.combine(hashableValue.hashValue) - } - - return hasher.combine(String(describing: value)) -} - - - -enum ViewIntentType: Int { - case image = 0 - case video = 1 -} - -/// Generated class from Pigeon that represents data sent in messages. -struct ViewIntentPayload: Hashable { - var path: String - var type: ViewIntentType - var mimeType: String - var localAssetId: String? = nil - - - // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> ViewIntentPayload? { - let path = pigeonVar_list[0] as! String - let type = pigeonVar_list[1] as! ViewIntentType - let mimeType = pigeonVar_list[2] as! String - let localAssetId: String? = nilOrValue(pigeonVar_list[3]) - - return ViewIntentPayload( - path: path, - type: type, - mimeType: mimeType, - localAssetId: localAssetId - ) - } - func toList() -> [Any?] { - return [ - path, - type, - mimeType, - localAssetId, - ] - } - static func == (lhs: ViewIntentPayload, rhs: ViewIntentPayload) -> Bool { - return deepEqualsViewIntent(lhs.toList(), rhs.toList()) } - func hash(into hasher: inout Hasher) { - deepHashViewIntent(value: toList(), hasher: &hasher) - } -} - -private class ViewIntentPigeonCodecReader: FlutterStandardReader { - override func readValue(ofType type: UInt8) -> Any? { - switch type { - case 129: - let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) - if let enumResultAsInt = enumResultAsInt { - return ViewIntentType(rawValue: enumResultAsInt) - } - return nil - case 130: - return ViewIntentPayload.fromList(self.readValue() as! [Any?]) - default: - return super.readValue(ofType: type) - } - } -} - -private class ViewIntentPigeonCodecWriter: FlutterStandardWriter { - override func writeValue(_ value: Any) { - if let value = value as? ViewIntentType { - super.writeByte(129) - super.writeValue(value.rawValue) - } else if let value = value as? ViewIntentPayload { - super.writeByte(130) - super.writeValue(value.toList()) - } else { - super.writeValue(value) - } - } -} - -private class ViewIntentPigeonCodecReaderWriter: FlutterStandardReaderWriter { - override func reader(with data: Data) -> FlutterStandardReader { - return ViewIntentPigeonCodecReader(data: data) - } - - override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return ViewIntentPigeonCodecWriter(data: data) - } -} - -class ViewIntentPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = ViewIntentPigeonCodec(readerWriter: ViewIntentPigeonCodecReaderWriter()) -} - - -/// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol ViewIntentHostApi { - func consumeViewIntent(completion: @escaping (Result) -> Void) -} - -/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class ViewIntentHostApiSetup { - static var codec: FlutterStandardMessageCodec { ViewIntentPigeonCodec.shared } - /// Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") { - let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let consumeViewIntentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - consumeViewIntentChannel.setMessageHandler { _, reply in - api.consumeViewIntent { result in - switch result { - case .success(let res): - reply(wrapResult(res)) - case .failure(let error): - reply(wrapError(error)) - } - } - } - } else { - consumeViewIntentChannel.setMessageHandler(nil) - } - } -} diff --git a/mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift b/mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift deleted file mode 100644 index 07ff78a582ac2..0000000000000 --- a/mobile/ios/Runner/ViewIntent/ViewIntentApiImpl.swift +++ /dev/null @@ -1,5 +0,0 @@ -class ViewIntentApiImpl: ViewIntentHostApi { - func consumeViewIntent(completion: @escaping (Result) -> Void) { - completion(.success(nil)) - } -} diff --git a/mobile/lib/models/view_intent/view_intent_attachment.model.dart b/mobile/lib/models/view_intent/view_intent_attachment.model.dart index 92f2b2f88cf21..c4bb4c259f612 100644 --- a/mobile/lib/models/view_intent/view_intent_attachment.model.dart +++ b/mobile/lib/models/view_intent/view_intent_attachment.model.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:path/path.dart'; enum ViewIntentAttachmentType { image, video } @@ -7,9 +8,15 @@ enum ViewIntentAttachmentType { image, video } class ViewIntentAttachment { final String path; final ViewIntentAttachmentType type; + final String mimeType; final String? localAssetId; - const ViewIntentAttachment({required this.path, required this.type, this.localAssetId}); + const ViewIntentAttachment({ + required this.path, + required this.type, + required this.mimeType, + this.localAssetId, + }); File get file => File(path); @@ -18,4 +25,22 @@ class ViewIntentAttachment { bool get isImage => type == ViewIntentAttachmentType.image; bool get isVideo => type == ViewIntentAttachmentType.video; + + AssetPlaybackStyle get playbackStyle { + if (isVideo) { + return AssetPlaybackStyle.video; + } + + final normalizedMimeType = mimeType.toLowerCase(); + if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') { + return AssetPlaybackStyle.imageAnimated; + } + + final normalizedPath = path.toLowerCase(); + if (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp')) { + return AssetPlaybackStyle.imageAnimated; + } + + return AssetPlaybackStyle.image; + } } diff --git a/mobile/lib/platform/view_intent_api.g.dart b/mobile/lib/platform/view_intent_api.g.dart index bcc1f3a8eef2f..4190d2cc1cf7e 100644 --- a/mobile/lib/platform/view_intent_api.g.dart +++ b/mobile/lib/platform/view_intent_api.g.dart @@ -14,25 +14,33 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } - bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { - return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && - a.entries.every( - (MapEntry entry) => - (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), - ); + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); } return a == b; } -enum ViewIntentType { image, video } + +enum ViewIntentType { + image, + video, +} class ViewIntentPayload { - ViewIntentPayload({required this.path, required this.type, required this.mimeType, this.localAssetId}); + ViewIntentPayload({ + required this.path, + required this.type, + required this.mimeType, + this.localAssetId, + }); String path; @@ -43,12 +51,16 @@ class ViewIntentPayload { String? localAssetId; List _toList() { - return [path, type, mimeType, localAssetId]; + return [ + path, + type, + mimeType, + localAssetId, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static ViewIntentPayload decode(Object result) { result as List; @@ -74,9 +86,11 @@ class ViewIntentPayload { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -84,10 +98,10 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is ViewIntentType) { + } else if (value is ViewIntentType) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is ViewIntentPayload) { + } else if (value is ViewIntentPayload) { buffer.putUint8(130); writeValue(buffer, value.encode()); } else { @@ -98,10 +112,10 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: final int? value = readValue(buffer) as int?; return value == null ? null : ViewIntentType.values[value]; - case 130: + case 130: return ViewIntentPayload.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -114,8 +128,8 @@ class ViewIntentHostApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -123,15 +137,15 @@ class ViewIntentHostApi { final String pigeonVar_messageChannelSuffix; Future consumeViewIntent() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index e4bace87631d2..b80658c5ac394 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -7,12 +7,9 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -89,8 +86,8 @@ class ViewIntentHandler { return; } } - - final fallbackAsset = _toViewIntentAsset(attachment); + final checksum = localAssetId != null ? await _computeChecksum(localAssetId) : null; + final fallbackAsset = _toViewIntentAsset(attachment).copyWith(checksum: checksum); _logger.fine('openAssetViewer for fallbackAsset'); _openAssetViewer(fallbackAsset, _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), 0); } @@ -135,13 +132,9 @@ class ViewIntentHandler { void _openAssetViewer(BaseAsset asset, TimelineService timelineService, int initialIndex) { _ref.read(assetViewerProvider.notifier).reset(); _ref.read(assetViewerProvider.notifier).setAsset(asset); - _ref.read(currentAssetNotifier.notifier).setAsset(asset); - if (asset.isVideo || asset.isMotionPhoto) { - _ref.read(videoPlaybackValueProvider.notifier).reset(); - _ref.read(videoPlayerControlsProvider.notifier).pause(); - } + if (asset.isVideo) { - _ref.read(assetViewerProvider.notifier).setControls(false); + _ref.read(assetViewerProvider.notifier).setControls(true); } _router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService)); @@ -163,13 +156,14 @@ class ViewIntentHandler { final now = DateTime.now(); return LocalAsset( - id: attachment.path, + id: attachment.localAssetId ?? '', name: attachment.fileName, checksum: null, type: attachment.isVideo ? AssetType.video : AssetType.image, createdAt: now, updatedAt: now, isEdited: false, + playbackStyle: attachment.playbackStyle, ); } } diff --git a/mobile/lib/repositories/view_handler.repository.dart b/mobile/lib/repositories/view_handler.repository.dart index 1feeb40f78647..007ca097e978a 100644 --- a/mobile/lib/repositories/view_handler.repository.dart +++ b/mobile/lib/repositories/view_handler.repository.dart @@ -18,6 +18,7 @@ class ViewHandlerRepository { return ViewIntentAttachment( path: result.path, type: result.type == ViewIntentType.image ? ViewIntentAttachmentType.image : ViewIntentAttachmentType.video, + mimeType: result.mimeType, localAssetId: result.localAssetId, ); } catch (_) { diff --git a/mobile/pigeon/view_intent_api.dart b/mobile/pigeon/view_intent_api.dart index 1e4f1515ed934..2a0ce77d3c157 100644 --- a/mobile/pigeon/view_intent_api.dart +++ b/mobile/pigeon/view_intent_api.dart @@ -3,8 +3,6 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon( PigeonOptions( dartOut: 'lib/platform/view_intent_api.g.dart', - swiftOut: 'ios/Runner/ViewIntent/ViewIntent.g.swift', - swiftOptions: SwiftOptions(includeErrorClass: false), kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.viewintent'), dartOptions: DartOptions(), diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index fa4a940695296..9bf6bed0e0b0b 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -13,6 +13,7 @@ void main() { const attachment = ViewIntentAttachment( path: '/tmp/file.jpg', type: ViewIntentAttachmentType.image, + mimeType: 'image/jpeg', localAssetId: '42', ); From b3b0b0f5762be968b2752f6d96c0f3fd236a0150 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 16 Apr 2026 14:34:08 +0300 Subject: [PATCH 09/14] refactor(mobile): simplify view intent flow and support file-backed ACTION_VIEW assets remove redundant view intent model/repository layer handle transient ACTION_VIEW files in viewer/upload flow clean up managed temp files for fallback assets --- .../view_intent_attachment.model.dart | 46 ---------- .../upload_action_button.widget.dart | 17 +++- .../asset_viewer/asset_page.widget.dart | 17 +++- .../asset_viewer/asset_viewer.page.dart | 5 +- .../asset_viewer/video_viewer.widget.dart | 18 +++- .../view_intent_file_path.provider.dart | 24 +++++ .../view_intent_handler.provider.dart | 39 +++++--- .../repositories/view_handler.repository.dart | 29 ------ mobile/lib/services/view_intent_service.dart | 75 ++++++++++++---- .../services/view_intent_service_test.dart | 88 +++++++++++++++---- 10 files changed, 233 insertions(+), 125 deletions(-) delete mode 100644 mobile/lib/models/view_intent/view_intent_attachment.model.dart create mode 100644 mobile/lib/providers/asset_viewer/view_intent_file_path.provider.dart delete mode 100644 mobile/lib/repositories/view_handler.repository.dart diff --git a/mobile/lib/models/view_intent/view_intent_attachment.model.dart b/mobile/lib/models/view_intent/view_intent_attachment.model.dart deleted file mode 100644 index c4bb4c259f612..0000000000000 --- a/mobile/lib/models/view_intent/view_intent_attachment.model.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:path/path.dart'; - -enum ViewIntentAttachmentType { image, video } - -class ViewIntentAttachment { - final String path; - final ViewIntentAttachmentType type; - final String mimeType; - final String? localAssetId; - - const ViewIntentAttachment({ - required this.path, - required this.type, - required this.mimeType, - this.localAssetId, - }); - - File get file => File(path); - - String get fileName => basename(file.path); - - bool get isImage => type == ViewIntentAttachmentType.image; - - bool get isVideo => type == ViewIntentAttachmentType.video; - - AssetPlaybackStyle get playbackStyle { - if (isVideo) { - return AssetPlaybackStyle.video; - } - - final normalizedMimeType = mimeType.toLowerCase(); - if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') { - return AssetPlaybackStyle.imageAnimated; - } - - final normalizedPath = path.toLowerCase(); - if (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp')) { - return AssetPlaybackStyle.imageAnimated; - } - - return AssetPlaybackStyle.image; - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 98eb09a4aa51f..8def3065b1a20 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -7,9 +8,11 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_ui/immich_ui.dart'; @@ -26,6 +29,7 @@ class UploadActionButton extends ConsumerWidget { } final isTimeline = source == ActionSource.timeline; + final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null; List? assets; if (source == ActionSource.timeline) { @@ -44,13 +48,22 @@ class UploadActionButton extends ConsumerWidget { ); } - final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); + var success = false; + if (!isTimeline && viewerIntentFilePath != null) { + var hasError = false; + await ref.read(foregroundUploadServiceProvider).uploadShareIntent([ + File(viewerIntentFilePath), + ], onError: (_, __) => hasError = true); + success = !hasError; + } else { + success = (await ref.read(actionProvider.notifier).upload(source, assets: assets)).success; + } if (!isTimeline && context.mounted) { Navigator.of(context, rootNavigator: true).pop(); } - if (context.mounted && !result.success) { + if (context.mounted && !success) { ImmichToast.show( context: context, msg: 'scaffold_body_error_occurred'.t(context: context), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 0934536471355..ebb42ed3dab51 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; @@ -13,14 +14,15 @@ import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -286,14 +288,17 @@ class _AssetPageState extends ConsumerState { required PhotoViewHeroAttributes? heroAttributes, required bool isCurrent, required bool isPlayingMotionVideo, + required String? localFilePath, }) { final size = context.sizeData; + final imageProvider = localFilePath != null ? FileImage(File(localFilePath)) : getFullImageProvider( + asset, size: size); if (asset.isImage && !isPlayingMotionVideo) { return PhotoView( key: Key(asset.heroTag), index: widget.index, - imageProvider: getFullImageProvider(asset, size: size), + imageProvider: imageProvider, heroAttributes: heroAttributes, loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), gaplessPlayback: true, @@ -340,9 +345,10 @@ class _AssetPageState extends ConsumerState { child: NativeVideoViewer( key: _NativeVideoViewerKey(asset.heroTag), asset: asset, + localFilePath: localFilePath, isCurrent: isCurrent, image: Image( - image: getFullImageProvider(asset, size: size), + image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center, ), @@ -383,6 +389,8 @@ class _AssetPageState extends ConsumerState { _scrollController.snapPosition.snapOffset = _snapOffset; } + final viewIntentFilePath = ref.watch(viewIntentFilePathProvider); + return Stack( children: [ SingleChildScrollView( @@ -402,6 +410,7 @@ class _AssetPageState extends ConsumerState { : null, isCurrent: isCurrent, isPlayingMotionVideo: isPlayingMotionVideo, + localFilePath: viewIntentFilePath, ), ), IgnorePointer( diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 3308ae8295efa..796cd49ed7643 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -17,9 +17,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -199,6 +199,7 @@ class _AssetViewerState extends ConsumerState { } void _onViewerReloadEvent() { + if (!mounted) return; if (_totalAssets <= 1) return; final index = _pageController.page?.round() ?? 0; diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 9285c01c41919..33efa46f5f32c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -9,8 +10,8 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; @@ -23,6 +24,7 @@ import 'package:native_video_player/native_video_player.dart'; class NativeVideoViewer extends ConsumerStatefulWidget { final BaseAsset asset; + final String? localFilePath; final bool isCurrent; final bool showControls; final Widget image; @@ -30,6 +32,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget { const NativeVideoViewer({ super.key, required this.asset, + this.localFilePath, required this.image, this.isCurrent = false, this.showControls = true, @@ -100,6 +103,19 @@ class _NativeVideoViewerState extends ConsumerState with Widg if (!mounted) return null; try { + final localFilePath = widget.localFilePath; + if (localFilePath != null) { + final file = File(localFilePath); + if (!await file.exists()) { + throw Exception('No file found for the video'); + } + + return VideoSource.init( + path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, + type: VideoSourceType.file, + ); + } + if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final file = await StorageRepository().getFileForAsset(id); diff --git a/mobile/lib/providers/asset_viewer/view_intent_file_path.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_file_path.provider.dart new file mode 100644 index 0000000000000..e776fb5b7966c --- /dev/null +++ b/mobile/lib/providers/asset_viewer/view_intent_file_path.provider.dart @@ -0,0 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ViewIntentFilePathNotifier extends Notifier { + @override + String? build() => null; + + void setPath(String path) { + if (state == path) { + return; + } + state = path; + } + + void clear() { + if (state == null) { + return; + } + state = null; + } +} + +final viewIntentFilePathProvider = NotifierProvider( + ViewIntentFilePathNotifier.new, +); diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index b80658c5ac394..ad826fb775178 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -5,9 +5,11 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; @@ -51,14 +53,14 @@ class ViewIntentHandler { unawaited(_viewIntentService.flushPending()); } - Future onViewMedia(List attachments) async { + Future onViewMedia(List attachments) async { if (attachments.isEmpty) { return; } await handle(attachments.first); } - Future handle(ViewIntentAttachment attachment) async { + Future handle(ViewIntentPayload attachment) async { _logger.info('handle attachment: $attachment'); if (!_ref.read(authProvider).isAuthenticated) { _viewIntentService.defer(attachment); @@ -87,9 +89,14 @@ class ViewIntentHandler { } } final checksum = localAssetId != null ? await _computeChecksum(localAssetId) : null; - final fallbackAsset = _toViewIntentAsset(attachment).copyWith(checksum: checksum); + final fallbackAsset = _toViewIntentAsset(attachment, checksum); _logger.fine('openAssetViewer for fallbackAsset'); - _openAssetViewer(fallbackAsset, _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), 0); + _openAssetViewer( + fallbackAsset, + _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), + 0, + viewIntentFilePath: attachment.path, + ); } Future _openFromMainTimeline(String localAssetId, {String? checksum}) async { @@ -129,9 +136,21 @@ class ViewIntentHandler { return false; } - void _openAssetViewer(BaseAsset asset, TimelineService timelineService, int initialIndex) { + void _openAssetViewer( + BaseAsset asset, + TimelineService timelineService, + int initialIndex, { + String? viewIntentFilePath, + }) { _ref.read(assetViewerProvider.notifier).reset(); _ref.read(assetViewerProvider.notifier).setAsset(asset); + if (viewIntentFilePath != null) { + _ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath); + unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath)); + } else { + _ref.read(viewIntentFilePathProvider.notifier).clear(); + unawaited(_viewIntentService.cleanupManagedTempFile()); + } if (asset.isVideo) { _ref.read(assetViewerProvider.notifier).setControls(true); @@ -152,13 +171,13 @@ class ViewIntentHandler { } } - LocalAsset _toViewIntentAsset(ViewIntentAttachment attachment) { + LocalAsset _toViewIntentAsset(ViewIntentPayload attachment, String? checksum) { final now = DateTime.now(); - return LocalAsset( - id: attachment.localAssetId ?? '', + // todo Temp solution, need to provide FileBackedAsset extends BaseAsset for cover this case in right way + id: attachment.localAssetId ?? '-${attachment.path.hashCode.abs()}', name: attachment.fileName, - checksum: null, + checksum: checksum, type: attachment.isVideo ? AssetType.video : AssetType.image, createdAt: now, updatedAt: now, diff --git a/mobile/lib/repositories/view_handler.repository.dart b/mobile/lib/repositories/view_handler.repository.dart deleted file mode 100644 index 007ca097e978a..0000000000000 --- a/mobile/lib/repositories/view_handler.repository.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; -import 'package:immich_mobile/platform/view_intent_api.g.dart'; - -final viewHandlerRepositoryProvider = Provider((ref) => ViewHandlerRepository(ViewIntentHostApi())); - -class ViewHandlerRepository { - final ViewIntentHostApi _viewIntentHostApi; - - const ViewHandlerRepository(this._viewIntentHostApi); - - Future checkViewIntent() async { - try { - final result = await _viewIntentHostApi.consumeViewIntent(); - if (result == null) { - return null; - } - return ViewIntentAttachment( - path: result.path, - type: result.type == ViewIntentType.image ? ViewIntentAttachmentType.image : ViewIntentAttachmentType.video, - mimeType: result.mimeType, - localAssetId: result.localAssetId, - ); - } catch (_) { - // Ignore errors - view intent might not be present - return null; - } - } -} diff --git a/mobile/lib/services/view_intent_service.dart b/mobile/lib/services/view_intent_service.dart index c3d322b83f821..2d85fba66181b 100644 --- a/mobile/lib/services/view_intent_service.dart +++ b/mobile/lib/services/view_intent_service.dart @@ -1,32 +1,77 @@ +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; -import 'package:immich_mobile/repositories/view_handler.repository.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:path/path.dart' as p; -final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ref.watch(viewHandlerRepositoryProvider))); +final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi())); class ViewIntentService { - final ViewHandlerRepository _viewHandlerRepository; - Future Function(List attachments)? onViewMedia; - ViewIntentAttachment? _pendingAttachment; + final ViewIntentHostApi _viewIntentHostApi; + Future Function(List attachments)? onViewMedia; + ViewIntentPayload? _pendingAttachment; + String? _managedTempFilePath; - ViewIntentService(this._viewHandlerRepository); + ViewIntentService(this._viewIntentHostApi); Future checkViewIntent() async { - final attachment = await _viewHandlerRepository.checkViewIntent(); - if (attachment != null) { - final handler = onViewMedia; - if (handler == null) { - _pendingAttachment = attachment; - return; + try { + final attachment = await _viewIntentHostApi.consumeViewIntent(); + if (attachment != null) { + final handler = onViewMedia; + if (handler == null) { + _pendingAttachment = attachment; + return; + } + await handler([attachment]); } - await handler([attachment]); + } catch (_) { + // Ignore errors - view intent might not be present } } - void defer(ViewIntentAttachment attachment) { + void defer(ViewIntentPayload attachment) { _pendingAttachment = attachment; } + Future setManagedTempFilePath(String path) async { + final previous = _managedTempFilePath; + if (previous == path) { + return; + } + _managedTempFilePath = path; + if (previous != null) { + await cleanupTempFile(previous); + } + } + + Future cleanupManagedTempFile() async { + final path = _managedTempFilePath; + _managedTempFilePath = null; + if (path != null) { + await cleanupTempFile(path); + } + } + + Future cleanupTempFile(String path) async { + if (!_isManagedTempFile(path)) { + return; + } + + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) { + // Best-effort cleanup only. + } + } + + bool _isManagedTempFile(String path) { + return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache'; + } + Future flushPending() async { final pendingAttachment = _pendingAttachment; final handler = onViewMedia; diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index 9bf6bed0e0b0b..1a2a039000de9 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -1,29 +1,34 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_attachment.model.dart'; -import 'package:immich_mobile/repositories/view_handler.repository.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/services/view_intent_service.dart'; import 'package:mocktail/mocktail.dart'; -class MockViewHandlerRepository extends Mock implements ViewHandlerRepository {} +class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {} void main() { - late MockViewHandlerRepository repository; + late MockViewIntentHostApi hostApi; late ViewIntentService service; - const attachment = ViewIntentAttachment( + final attachment = ViewIntentPayload( path: '/tmp/file.jpg', - type: ViewIntentAttachmentType.image, + type: ViewIntentType.image, mimeType: 'image/jpeg', localAssetId: '42', ); setUp(() { - repository = MockViewHandlerRepository(); - service = ViewIntentService(repository); + hostApi = MockViewIntentHostApi(); + service = ViewIntentService(hostApi); + }); + + tearDown(() async { + clearInteractions(hostApi); }); test('checkViewIntent does nothing when no attachment', () async { - when(() => repository.checkViewIntent()).thenAnswer((_) async => null); + when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null); var called = 0; service.onViewMedia = (_) async { @@ -33,13 +38,13 @@ void main() { await service.checkViewIntent(); expect(called, 0); - verify(() => repository.checkViewIntent()).called(1); + verify(() => hostApi.consumeViewIntent()).called(1); }); test('checkViewIntent calls handler immediately when handler is set', () async { - when(() => repository.checkViewIntent()).thenAnswer((_) async => attachment); + when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment); - List? received; + List? received; service.onViewMedia = (attachments) async { received = attachments; }; @@ -49,15 +54,15 @@ void main() { expect(received, isNotNull); expect(received, hasLength(1)); expect(received!.first, attachment); - verify(() => repository.checkViewIntent()).called(1); + verify(() => hostApi.consumeViewIntent()).called(1); }); test('checkViewIntent stores pending attachment when handler is not set', () async { - when(() => repository.checkViewIntent()).thenAnswer((_) async => attachment); + when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment); await service.checkViewIntent(); - List? received; + List? received; service.onViewMedia = (attachments) async { received = attachments; }; @@ -75,7 +80,7 @@ void main() { await service.flushPending(); var called = 0; - List? received; + List? received; service.onViewMedia = (attachments) async { called++; received = attachments; @@ -88,4 +93,55 @@ void main() { expect(received, hasLength(1)); expect(received!.first, attachment); }); + + test('checkViewIntent swallows host api errors', () async { + when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom')); + + var called = 0; + service.onViewMedia = (_) async { + called++; + }; + + await service.checkViewIntent(); + + expect(called, 0); + verify(() => hostApi.consumeViewIntent()).called(1); + }); + + test('setManagedTempFilePath cleans previous managed temp file', () async { + final tempRoot = await Directory.systemTemp.createTemp('view-intent-root'); + final cacheDir = Directory('${tempRoot.path}/cache')..createSync(); + addTearDown(() async { + if (await tempRoot.exists()) { + await tempRoot.delete(recursive: true); + } + }); + + final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first'); + final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second'); + + await service.setManagedTempFilePath(firstFile.path); + await service.setManagedTempFilePath(secondFile.path); + + expect(await firstFile.exists(), isFalse); + expect(await secondFile.exists(), isTrue); + + await service.cleanupManagedTempFile(); + expect(await secondFile.exists(), isFalse); + }); + + test('cleanupTempFile ignores non-managed paths', () async { + final tempDir = await Directory.systemTemp.createTemp('view-intent-test'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final nonManagedFile = File('${tempDir.path}/plain_file.jpg')..writeAsStringSync('content'); + + await service.cleanupTempFile(nonManagedFile.path); + + expect(await nonManagedFile.exists(), isTrue); + }); } From 0d4d59c7e728a64a3d88b7c906d5e0601dcf4bb8 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 17 Apr 2026 12:43:24 +0300 Subject: [PATCH 10/14] refactor(mobile): extract MediaStore utils and resolve view intents via merged assets --- .../immich/BackgroundServicePlugin.kt | 27 +-- .../alextran/immich/media/MediaStoreUtils.kt | 103 ++++++++ .../immich/viewintent/ViewIntentPlugin.kt | 81 +------ .../entities/merged_asset.drift | 98 ++++++++ .../entities/merged_asset.drift.dart | 46 ++++ .../repositories/timeline.repository.dart | 20 ++ mobile/lib/main.dart | 2 +- .../view_intent_handler.provider.dart | 120 +-------- ..._service.dart => view_intent.service.dart} | 0 .../view_intent_asset_resolver.service.dart | 228 ++++++++++++++++++ .../services/view_intent_service_test.dart | 2 +- 11 files changed, 520 insertions(+), 207 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt rename mobile/lib/services/{view_intent_service.dart => view_intent.service.dart} (100%) create mode 100644 mobile/lib/services/view_intent_asset_resolver.service.dart diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt index f62f25558d5b5..d3d8bdd35d72c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt @@ -8,11 +8,12 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle -import android.provider.MediaStore -import android.provider.Settings -import android.util.Log -import androidx.annotation.RequiresApi -import io.flutter.embedding.engine.plugins.FlutterPlugin +import android.provider.MediaStore +import android.provider.Settings +import android.util.Log +import androidx.annotation.RequiresApi +import app.alextran.immich.media.MediaStoreUtils +import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger @@ -254,7 +255,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, return } - val uri = ContentUris.withAppendedId(contentUriForType(type), id) + val uri = ContentUris.withAppendedId(MediaStoreUtils.contentUriForAssetType(type), id) try { Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)") @@ -305,7 +306,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor -> if (cursor.moveToFirst()) { val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) - return ContentUris.withAppendedId(contentUriForType(type), id) + return ContentUris.withAppendedId(MediaStoreUtils.contentUriForAssetType(type), id) } } return null @@ -373,17 +374,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}") toggleTrash(uris, false, result) } - - @RequiresApi(Build.VERSION_CODES.Q) - 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) - } -} +} private const val TAG = "BackgroundServicePlugin" private const val BUFFER_SIZE = 2 * 1024 * 1024 diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt new file mode 100644 index 0000000000000..3af520bb7737c --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt @@ -0,0 +1,103 @@ +package app.alextran.immich.media + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.provider.OpenableColumns + +object MediaStoreUtils { + private fun externalFilesUri(): Uri = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + } else { + MediaStore.Files.getContentUri("external") + } + + fun contentUriForMimeType(mimeType: String): Uri = + when { + mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + mimeType.startsWith("audio/") -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> externalFilesUri() + } + + fun contentUriForAssetType(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 -> externalFilesUri() + } + + fun resolveLocalIdByRelativePath(context: Context, path: String, mimeType: String): String? { + val fileName = path.substringAfterLast('/', missingDelimiterValue = path) + val parent = path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" } + if (fileName.isBlank()) return null + + val (selection, args) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?" to arrayOf(fileName, parent) + } else { + "${MediaStore.MediaColumns.DISPLAY_NAME}=?" to arrayOf(fileName) + } + + return queryLatestId( + context = context, + tableUri = contentUriForMimeType(mimeType), + selection = selection, + selectionArgs = args, + ) + } + + fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? { + val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + val (displayName, size) = + try { + context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) return null + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null + val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L + if (name.isNullOrBlank() || bytes < 0) return null + name to bytes + } ?: return null + } catch (_: Exception) { + return null + } + + return queryLatestId( + context = context, + tableUri = contentUriForMimeType(mimeType), + selection = "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?", + selectionArgs = arrayOf(displayName, size.toString()), + ) + } + + private fun queryLatestId( + context: Context, + tableUri: Uri, + selection: String, + selectionArgs: Array, + ): String? { + return try { + context.contentResolver + .query( + tableUri, + arrayOf(MediaStore.MediaColumns._ID), + selection, + selectionArgs, + "${MediaStore.MediaColumns.DATE_MODIFIED} DESC", + )?.use { cursor -> + if (!cursor.moveToFirst()) return null + val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + if (idIndex < 0) return null + cursor.getLong(idIndex).toString() + } + } catch (_: Exception) { + null + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt index 5407e782b2eb4..1ad1d41fa36ed 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -5,10 +5,8 @@ import android.content.ContentUris import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.provider.DocumentsContract -import android.provider.MediaStore -import android.provider.OpenableColumns +import app.alextran.immich.media.MediaStoreUtils import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -125,7 +123,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL if (parsed.all(Char::isDigit)) { return parsed } - val fromRelativePath = resolveLocalIdByRelativePath(context, parsed, mimeType) + val fromRelativePath = MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType) if (fromRelativePath != null) { return fromRelativePath } @@ -149,80 +147,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL return segment } - return resolveLocalIdByNameAndSize(context, uri, mimeType) - } - - private fun resolveLocalIdByRelativePath(context: Context, path: String, mimeType: String): String? { - val fileName = path.substringAfterLast('/', missingDelimiterValue = path) - val parent = path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" } - if (fileName.isBlank()) return null - - val tableUri = when { - mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - else -> MediaStore.Files.getContentUri("external") - } - - val projection = arrayOf(MediaStore.MediaColumns._ID) - val (selection, args) = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?" to arrayOf(fileName, parent) - } else { - "${MediaStore.MediaColumns.DISPLAY_NAME}=?" to arrayOf(fileName) - } - - return try { - context.contentResolver - .query(tableUri, projection, selection, args, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC") - ?.use { cursor -> - if (!cursor.moveToFirst()) return null - val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) - if (idIndex < 0) return null - cursor.getLong(idIndex).toString() - } - } catch (_: Exception) { - null - } - } - - private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? { - val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) - val (displayName, size) = - try { - context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor -> - if (!cursor.moveToFirst()) return null - val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) - val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null - val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L - if (name.isNullOrBlank() || bytes < 0) return null - name to bytes - } ?: return null - } catch (_: Exception) { - return null - } - - val tableUri = when { - mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI - mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI - else -> MediaStore.Files.getContentUri("external") - } - val projection = arrayOf(MediaStore.MediaColumns._ID) - val selection = "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?" - val args = arrayOf(displayName, size.toString()) - - return try { - context.contentResolver - .query(tableUri, projection, selection, args, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC") - ?.use { cursor -> - if (!cursor.moveToFirst()) return null - val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) - if (idIndex < 0) return null - cursor.getLong(idIndex).toString() - } - } catch (_: Exception) { - null - } + return MediaStoreUtils.resolveLocalIdByNameAndSize(context, uri, mimeType) } private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? { diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index 73276d175675a..ce3372e54b576 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -137,3 +137,101 @@ FROM ) GROUP BY bucket_date ORDER BY bucket_date DESC; + +mergedAssetIndexByLocalId: +SELECT + idx +FROM ( + SELECT + local_id, + ROW_NUMBER() OVER (ORDER BY created_at DESC) - 1 as idx + FROM ( + SELECT + (SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id, + rae.created_at as created_at + FROM + remote_asset_entity rae + LEFT JOIN + stack_entity se ON rae.stack_id = se.id + WHERE + rae.deleted_at IS NULL + AND rae.visibility = 0 -- timeline visibility + AND rae.owner_id IN :user_ids + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) + + UNION ALL + + SELECT + lae.id as local_id, + lae.created_at as created_at + FROM + local_asset_entity lae + WHERE NOT EXISTS ( + SELECT 1 FROM remote_asset_entity rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN :user_ids + ) + AND EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 0 -- selected + ) + AND NOT EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded + ) + ) +) +WHERE local_id = :local_asset_id +LIMIT 1; + +mergedAssetIndexByChecksum: +SELECT + idx +FROM ( + SELECT + checksum, + ROW_NUMBER() OVER (ORDER BY created_at DESC) - 1 as idx + FROM ( + SELECT + rae.checksum as checksum, + rae.created_at as created_at + FROM + remote_asset_entity rae + LEFT JOIN + stack_entity se ON rae.stack_id = se.id + WHERE + rae.deleted_at IS NULL + AND rae.visibility = 0 -- timeline visibility + AND rae.owner_id IN :user_ids + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) + + UNION ALL + + SELECT + lae.checksum as checksum, + lae.created_at as created_at + FROM + local_asset_entity lae + WHERE NOT EXISTS ( + SELECT 1 FROM remote_asset_entity rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN :user_ids + ) + AND EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 0 -- selected + ) + AND NOT EXISTS ( + SELECT 1 FROM local_album_asset_entity laa + INNER JOIN local_album_entity la on laa.album_id = la.id + WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded + ) + ) +) +WHERE checksum = :checksum +LIMIT 1; diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index c6004eb10d95d..2e15e0fe60fc4 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -100,6 +100,52 @@ class MergedAssetDrift extends i1.ModularAccessor { ); } + i0.Selectable mergedAssetIndexByLocalId({ + required List userIds, + String? localAssetId, + }) { + var $arrayStartIndex = 2; + final expandeduserIds = $expandVar($arrayStartIndex, userIds.length); + $arrayStartIndex += userIds.length; + return customSelect( + 'SELECT idx FROM (SELECT local_id, ROW_NUMBER()OVER (ORDER BY created_at DESC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) - 1 AS idx FROM (SELECT (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.created_at AS created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.id AS local_id, lae.created_at AS created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2))) WHERE local_id = ?1 LIMIT 1', + variables: [ + i0.Variable(localAssetId), + for (var $ in userIds) i0.Variable($), + ], + readsFrom: { + localAssetEntity, + remoteAssetEntity, + stackEntity, + localAlbumAssetEntity, + localAlbumEntity, + }, + ).map((i0.QueryRow row) => row.read('idx')); + } + + i0.Selectable mergedAssetIndexByChecksum({ + required List userIds, + String? checksum, + }) { + var $arrayStartIndex = 2; + final expandeduserIds = $expandVar($arrayStartIndex, userIds.length); + $arrayStartIndex += userIds.length; + return customSelect( + 'SELECT idx FROM (SELECT checksum, ROW_NUMBER()OVER (ORDER BY created_at DESC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) - 1 AS idx FROM (SELECT rae.checksum AS checksum, rae.created_at AS created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.checksum AS checksum, lae.created_at AS created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2))) WHERE checksum = ?1 LIMIT 1', + variables: [ + i0.Variable(checksum), + for (var $ in userIds) i0.Variable($), + ], + readsFrom: { + remoteAssetEntity, + stackEntity, + localAssetEntity, + localAlbumAssetEntity, + localAlbumEntity, + }, + ).map((i0.QueryRow row) => row.read('idx')); + } + i4.$RemoteAssetEntityTable get remoteAssetEntity => i1.ReadDatabaseContainer( attachedDatabase, ).resultSet('remote_asset_entity'); diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 74af6dc3f0199..282de000555f1 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -672,6 +672,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } } + + Future getMainTimelineIndexByChecksum(List userIds, String checksum) async { + if (userIds.isEmpty) { + return null; + } + final result = await _db.mergedAssetDrift + .mergedAssetIndexByChecksum(userIds: userIds, checksum: checksum) + .getSingleOrNull(); + return result; + } + + Future getMainTimelineIndexByLocalId(List userIds, String localAssetId) async { + if (userIds.isEmpty) { + return null; + } + final result = await _db.mergedAssetDrift + .mergedAssetIndexByLocalId(userIds: userIds, localAssetId: localAssetId) + .getSingleOrNull(); + return result; + } } List _generateBuckets(int count) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c75a41d0a1f8e..7dfacc1fa4be7 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -37,7 +37,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; -import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart index ad826fb775178..f621342a28b7a 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart @@ -1,50 +1,38 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; -import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; +import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; import 'package:logging/logging.dart'; final viewIntentHandlerProvider = Provider( (ref) => ViewIntentHandler( ref, ref.read(viewIntentServiceProvider), + ref.read(viewIntentAssetResolverProvider), ref.watch(appRouterProvider), - ref.read(localAssetRepository), - ref.read(nativeSyncApiProvider), - ref.read(timelineFactoryProvider), ), ); class ViewIntentHandler { final Ref _ref; final ViewIntentService _viewIntentService; + final ViewIntentAssetResolver _viewIntentAssetResolver; final AppRouter _router; - final DriftLocalAssetRepository _localAssetRepository; - final NativeSyncApi _nativeSyncApi; - final TimelineFactory _timelineFactory; static final Logger _logger = Logger('ViewIntentHandler'); const ViewIntentHandler( this._ref, this._viewIntentService, + this._viewIntentAssetResolver, this._router, - this._localAssetRepository, - this._nativeSyncApi, - this._timelineFactory, ); void init() { @@ -67,75 +55,16 @@ class ViewIntentHandler { return; } - final localAssetId = attachment.localAssetId; - _logger.fine('localAssetId: $localAssetId'); - if (localAssetId != null) { - final localAsset = await _localAssetRepository.getById(localAssetId); - if (localAsset != null) { - var checksum = localAsset.checksum; - if (checksum == null) { - checksum = await _computeChecksum(localAssetId); - if (checksum != null) { - await _localAssetRepository.updateHashes({localAssetId: checksum}); - } - } - final timelineMatch = await _openFromMainTimeline(localAssetId, checksum: checksum); - _logger.fine('localAsset: $localAsset, checksum: $checksum, timelineMatch: $timelineMatch'); - if (timelineMatch) { - return; - } - _openAssetViewer(localAsset, _timelineFactory.fromAssets([localAsset], TimelineOrigin.deepLink), 0); - return; - } - } - final checksum = localAssetId != null ? await _computeChecksum(localAssetId) : null; - final fallbackAsset = _toViewIntentAsset(attachment, checksum); - _logger.fine('openAssetViewer for fallbackAsset'); + final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); + _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); _openAssetViewer( - fallbackAsset, - _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), - 0, - viewIntentFilePath: attachment.path, + resolvedAsset.asset, + resolvedAsset.timelineService, + resolvedAsset.initialIndex, + viewIntentFilePath: resolvedAsset.viewIntentFilePath, ); } - Future _openFromMainTimeline(String localAssetId, {String? checksum}) async { - final timelineService = _ref.read(timelineServiceProvider); - if (timelineService.totalAssets == 0) { - try { - await timelineService.watchBuckets().first.timeout(const Duration(seconds: 2)); - } catch (_) { - // Ignore and fallback. - } - } - - final totalAssets = timelineService.totalAssets; - if (totalAssets == 0) { - return false; - } - - final batchSize = kTimelineAssetLoadBatchSize; - for (var offset = 0; offset < totalAssets; offset += batchSize) { - final count = (offset + batchSize > totalAssets) ? totalAssets - offset : batchSize; - final assets = await timelineService.loadAssets(offset, count); - final indexInBatch = assets.indexWhere((asset) { - if (asset.localId == localAssetId) { - return true; - } - if (checksum != null && asset.checksum == checksum) { - return true; - } - return false; - }); - if (indexInBatch >= 0) { - final asset = assets[indexInBatch]; - _openAssetViewer(asset, timelineService, offset + indexInBatch); - return true; - } - } - return false; - } - void _openAssetViewer( BaseAsset asset, TimelineService timelineService, @@ -158,31 +87,4 @@ class ViewIntentHandler { _router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService)); } - - Future _computeChecksum(String localAssetId) async { - try { - final hashResults = await _nativeSyncApi.hashAssets([localAssetId]); - if (hashResults.isEmpty) { - return null; - } - return hashResults.first.hash; - } catch (_) { - return null; - } - } - - LocalAsset _toViewIntentAsset(ViewIntentPayload attachment, String? checksum) { - final now = DateTime.now(); - return LocalAsset( - // todo Temp solution, need to provide FileBackedAsset extends BaseAsset for cover this case in right way - id: attachment.localAssetId ?? '-${attachment.path.hashCode.abs()}', - name: attachment.fileName, - checksum: checksum, - type: attachment.isVideo ? AssetType.video : AssetType.image, - createdAt: now, - updatedAt: now, - isEdited: false, - playbackStyle: attachment.playbackStyle, - ); - } } diff --git a/mobile/lib/services/view_intent_service.dart b/mobile/lib/services/view_intent.service.dart similarity index 100% rename from mobile/lib/services/view_intent_service.dart rename to mobile/lib/services/view_intent.service.dart diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart new file mode 100644 index 0000000000000..f3e2e269e7762 --- /dev/null +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/background.service.dart'; +import 'package:logging/logging.dart'; + +class ViewIntentResolvedAsset { + final BaseAsset asset; + final TimelineService timelineService; + final int initialIndex; + final String? viewIntentFilePath; + + const ViewIntentResolvedAsset({ + required this.asset, + required this.timelineService, + required this.initialIndex, + this.viewIntentFilePath, + }); +} + +final viewIntentAssetResolverProvider = Provider( + (ref) => ViewIntentAssetResolver( + ref, + ref.read(localAssetRepository), + ref.read(nativeSyncApiProvider), + ref.read(backgroundServiceProvider), + ref.read(timelineFactoryProvider), + ), +); + +class ViewIntentAssetResolver { + final Ref _ref; + final DriftLocalAssetRepository _localAssetRepository; + final NativeSyncApi _nativeSyncApi; + final BackgroundService _backgroundService; + final TimelineFactory _timelineFactory; + static final Logger _logger = Logger('ViewIntentAssetResolver'); + + const ViewIntentAssetResolver( + this._ref, + this._localAssetRepository, + this._nativeSyncApi, + this._backgroundService, + this._timelineFactory, + ); + + Future resolve(ViewIntentPayload attachment) async { + final localAssetId = attachment.localAssetId; + if (localAssetId != null) { + var localAsset = await _localAssetRepository.getById(localAssetId); + if (localAsset != null) { + // Try the direct local-id match first when the intent resolves to a + // real MediaStore asset. + final mainTimelineAsset = await _resolveMainTimelineAssetByLocalId(localAssetId); + if (mainTimelineAsset != null) { + _logger.fine('presenting main timeline asset via localAssetId: ${mainTimelineAsset.asset}'); + return mainTimelineAsset; + } + + var checksum = localAsset.checksum; + if (checksum == null) { + checksum = await _computeChecksumForLocalAsset(localAssetId); + if (checksum != null) { + localAsset = localAsset.copyWith(checksum: checksum); + } + } + + // If local id does not match the merged timeline, retry by checksum + // because the same asset may already be represented there as a merged + // local/remote asset. + if (checksum != null) { + final checksumTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum); + if (checksumTimelineAsset != null) { + _logger.fine('presenting main timeline asset via checksum fallback: ${checksumTimelineAsset.asset}'); + return checksumTimelineAsset; + } + } + + _logger.fine('presenting deep-link local asset: $localAsset'); + return ViewIntentResolvedAsset( + asset: localAsset, + timelineService: _timelineFactory.fromAssets([localAsset], TimelineOrigin.deepLink), + initialIndex: 0, + ); + } + } + + final checksum = await _computeChecksum(attachment); + if (checksum != null) { + // Some ACTION_VIEW sources do not provide a local MediaStore id, so + // checksum is the only way to match the incoming file to an existing + // merged asset. + final mainTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum); + if (mainTimelineAsset != null) { + _logger.fine('presenting main timeline asset via checksum-only match: ${mainTimelineAsset.asset}'); + return mainTimelineAsset; + } + } + + final fallbackAsset = _toViewIntentAsset(attachment, checksum); + _logger.fine('presenting transient fallback asset: $fallbackAsset'); + return ViewIntentResolvedAsset( + asset: fallbackAsset, + timelineService: _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), + initialIndex: 0, + viewIntentFilePath: attachment.path, + ); + } + + Future _resolveMainTimelineAssetByLocalId(String localAssetId) async { + final effectiveTimelineUsers = _resolveMainTimelineUsers(); + if (effectiveTimelineUsers.isEmpty) { + return null; + } + + final index = await _ref + .read(timelineRepositoryProvider) + .getMainTimelineIndexByLocalId(effectiveTimelineUsers, localAssetId); + if (index == null) { + return null; + } + + return _resolveMainTimelineAssetAt(index); + } + + Future _resolveMainTimelineAssetByChecksum(String checksum) async { + final effectiveTimelineUsers = _resolveMainTimelineUsers(); + if (effectiveTimelineUsers.isEmpty) { + return null; + } + + final index = await _ref + .read(timelineRepositoryProvider) + .getMainTimelineIndexByChecksum(effectiveTimelineUsers, checksum); + if (index == null) { + return null; + } + + return _resolveMainTimelineAssetAt(index); + } + + List _resolveMainTimelineUsers() { + final timelineUsers = _ref.read(timelineUsersProvider).valueOrNull; + final currentUserId = _ref.read(currentUserProvider)?.id; + return timelineUsers ?? (currentUserId != null ? [currentUserId] : const []); + } + + Future _resolveMainTimelineAssetAt(int index) async { + final timelineService = _ref.read(timelineServiceProvider); + if (timelineService.totalAssets == 0) { + try { + await timelineService.watchBuckets().first.timeout(const Duration(seconds: 2)); + } catch (_) { + return null; + } + } + + if (index >= timelineService.totalAssets) { + return null; + } + + final asset = await timelineService.getAssetAsync(index); + if (asset == null) { + return null; + } + + return ViewIntentResolvedAsset(asset: asset, timelineService: timelineService, initialIndex: index); + } + + Future _computeChecksum(ViewIntentPayload attachment) async { + final localAssetId = attachment.localAssetId; + if (localAssetId != null) { + return _computeChecksumForLocalAsset(localAssetId); + } + return _computeChecksumForPath(attachment.path); + } + + Future _computeChecksumForLocalAsset(String localAssetId) async { + try { + final hashResults = await _nativeSyncApi.hashAssets([localAssetId]); + if (hashResults.isEmpty) { + return null; + } + return hashResults.first.hash; + } catch (_) { + return null; + } + } + + Future _computeChecksumForPath(String path) async { + try { + final hashes = await _backgroundService.digestFiles([path]); + final hash = hashes == null || hashes.isEmpty ? null : hashes.first; + if (hash == null || hash.length != 20) { + return null; + } + return base64.encode(hash); + } catch (_) { + return null; + } + } + + LocalAsset _toViewIntentAsset(ViewIntentPayload attachment, String? checksum) { + final now = DateTime.now(); + return LocalAsset( + // todo Temp solution, need to provide FileBackedAsset extends BaseAsset for cover this case in right way + id: attachment.localAssetId ?? '-${attachment.path.hashCode.abs()}', + name: attachment.fileName, + checksum: checksum, + type: attachment.isVideo ? AssetType.video : AssetType.image, + createdAt: now, + updatedAt: now, + isEdited: false, + playbackStyle: attachment.playbackStyle, + ); + } +} diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index 1a2a039000de9..e3cdaef15357e 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; -import 'package:immich_mobile/services/view_intent_service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:mocktail/mocktail.dart'; class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {} From 4354431327a1ab30cbc86b3d47d67380277613f0 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 17 Apr 2026 16:21:22 +0300 Subject: [PATCH 11/14] refactor(mobile): move deferred view intents into providers, split view-intent providers, and clean up ACTION_VIEW handling --- .../android/app/src/main/AndroidManifest.xml | 8 +- .../immich/viewintent/ViewIntentPlugin.kt | 173 +++++++++++------- mobile/lib/main.dart | 43 +++-- .../view_intent_payload.extension.dart | 33 ++++ .../upload_action_button.widget.dart | 2 +- .../asset_viewer/asset_page.widget.dart | 13 +- .../view_intent_file_path.provider.dart | 0 .../view_intent_handler.provider.dart | 32 ++-- .../view_intent_pending.provider.dart | 39 ++++ mobile/lib/services/view_intent.service.dart | 29 +-- .../view_intent_pending_provider_test.dart | 66 +++++++ .../services/view_intent_service_test.dart | 72 +------- 12 files changed, 303 insertions(+), 207 deletions(-) create mode 100644 mobile/lib/models/view_intent/view_intent_payload.extension.dart rename mobile/lib/providers/{asset_viewer => view_intent}/view_intent_file_path.provider.dart (100%) rename mobile/lib/providers/{asset_viewer => view_intent}/view_intent_handler.provider.dart (75%) create mode 100644 mobile/lib/providers/view_intent/view_intent_pending.provider.dart create mode 100644 mobile/test/providers/view_intent/view_intent_pending_provider_test.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index bcc8b00887f0a..32ec44982fef4 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -97,16 +97,16 @@ - - + + - - + + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt index 1ad1d41fa36ed..8219fd7d365cf 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -6,6 +6,8 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.provider.DocumentsContract +import android.util.Log +import android.webkit.MimeTypeMap import app.alextran.immich.media.MediaStoreUtils import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -13,12 +15,19 @@ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.PluginRegistry import java.io.File import java.io.FileOutputStream -import java.io.InputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +private const val TAG = "ViewIntentPlugin" class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi { private var context: Context? = null private var activity: Activity? = null private var pendingIntent: Intent? = null + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { context = binding.applicationContext @@ -27,6 +36,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { ViewIntentHostApi.setUp(binding.binaryMessenger, null) + ioScope.cancel() context = null } @@ -71,29 +81,31 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL return } - val mimeType = context.contentResolver.getType(uri) - if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) { - callback(Result.success(null)) - return - } + ioScope.launch { + try { + val mimeType = context.contentResolver.getType(uri) + if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) { + callback(Result.success(null)) + return@launch + } - try { - val tempFile = copyUriToTempFile(context, uri, mimeType) - if (tempFile == null) { - callback(Result.success(null)) - return - } + val tempFile = copyUriToTempFile(context, uri, mimeType) + if (tempFile == null) { + callback(Result.success(null)) + return@launch + } - val payload = ViewIntentPayload( - path = tempFile.absolutePath, - type = if (mimeType.startsWith("image/")) ViewIntentType.IMAGE else ViewIntentType.VIDEO, - mimeType = mimeType, - localAssetId = extractLocalAssetId(context, uri, mimeType), - ) - consumeViewIntent(intent) - callback(Result.success(payload)) - } catch (e: Exception) { - callback(Result.failure(e)) + val payload = ViewIntentPayload( + path = tempFile.absolutePath, + type = if (mimeType.startsWith("image/")) ViewIntentType.IMAGE else ViewIntentType.VIDEO, + mimeType = mimeType, + localAssetId = extractLocalAssetId(context, uri, mimeType), + ) + consumeViewIntent(intent) + callback(Result.success(payload)) + } catch (e: Exception) { + callback(Result.failure(e)) + } } } @@ -111,76 +123,101 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL return null } - try { - if (DocumentsContract.isDocumentUri(context, uri)) { - val docId = DocumentsContract.getDocumentId(uri) - if (docId.startsWith("raw:")) { - return null - } + val fromDocumentUri = tryExtractDocumentLocalAssetId(context, uri, mimeType) + if (fromDocumentUri != null) { + return fromDocumentUri + } - if (docId.isNotBlank()) { - val parsed = docId.substringAfter(':', docId) - if (parsed.all(Char::isDigit)) { - return parsed - } - val fromRelativePath = MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType) - if (fromRelativePath != null) { - return fromRelativePath - } - } - } - } catch (_: Exception) { - // Ignore and continue with fallback strategy. + val fromContentUri = tryParseContentUriId(uri) + if (fromContentUri != null) { + return fromContentUri } + val fromPathSegment = tryParseLastPathSegmentId(uri) + if (fromPathSegment != null) { + return fromPathSegment + } + + return MediaStoreUtils.resolveLocalIdByNameAndSize(context, uri, mimeType) + } + + private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri, mimeType: String): String? { try { - val parsed = ContentUris.parseId(uri) - if (parsed >= 0) { - return parsed.toString() + if (!DocumentsContract.isDocumentUri(context, uri)) { + return null } - } catch (_: Exception) { - // Ignore and continue with fallback strategy. + + val docId = DocumentsContract.getDocumentId(uri) + if (docId.startsWith("raw:")) { + return null + } + + if (docId.isBlank()) { + return null + } + + val parsed = docId.substringAfter(':', docId) + if (parsed.all(Char::isDigit)) { + return parsed + } + + return MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType) + } catch (e: Exception) { + Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e) + return null } + } - val segment = uri.lastPathSegment - if (segment != null && segment.all(Char::isDigit)) { - return segment + private fun tryParseContentUriId(uri: Uri): String? { + return try { + val parsed = ContentUris.parseId(uri) + if (parsed >= 0) parsed.toString() else null + } catch (e: Exception) { + Log.w(TAG, "Failed to parse local asset id from content URI: $uri", e) + null } + } - return MediaStoreUtils.resolveLocalIdByNameAndSize(context, uri, mimeType) + private fun tryParseLastPathSegmentId(uri: Uri): String? { + val segment = uri.lastPathSegment ?: return null + return if (segment.all(Char::isDigit)) segment else null } private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? { return try { - val inputStream: InputStream? = context.contentResolver.openInputStream(uri) - if (inputStream == null) return null + val normalizedMimeType = mimeType.substringBefore(';').lowercase() + val mimeTypeExtension = MimeTypeMap + .getSingleton() + .getExtensionFromMimeType(normalizedMimeType) + ?.let { ".$it" } val extension = when { - mimeType.startsWith("image/") -> { + normalizedMimeType.startsWith("image/") -> { when { - mimeType.contains("jpeg") || mimeType.contains("jpg") -> ".jpg" - mimeType.contains("png") -> ".png" - mimeType.contains("gif") -> ".gif" - mimeType.contains("webp") -> ".webp" - else -> ".jpg" + normalizedMimeType.contains("jpeg") || normalizedMimeType.contains("jpg") -> ".jpg" + normalizedMimeType.contains("png") -> ".png" + normalizedMimeType.contains("gif") -> ".gif" + normalizedMimeType.contains("webp") -> ".webp" + else -> mimeTypeExtension ?: ".jpg" } } - mimeType.startsWith("video/") -> { + normalizedMimeType.startsWith("video/") -> { when { - mimeType.contains("mp4") -> ".mp4" - mimeType.contains("webm") -> ".webm" - mimeType.contains("3gp") -> ".3gp" - else -> ".mp4" + normalizedMimeType.contains("mp4") -> ".mp4" + normalizedMimeType.contains("webm") -> ".webm" + normalizedMimeType.contains("3gp") -> ".3gp" + else -> mimeTypeExtension ?: ".mp4" } } - else -> ".tmp" + else -> mimeTypeExtension ?: ".tmp" } val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir) - val outputStream = FileOutputStream(tempFile) - inputStream.copyTo(outputStream) - inputStream.close() - outputStream.close() + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } ?: return null tempFile } catch (_: Exception) { null diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 7dfacc1fa4be7..087374ab15bf8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -24,7 +24,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/view_intent_handler.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -32,14 +32,13 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; -import 'package:immich_mobile/routing/app_navigation_observer.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/background.service.dart'; -import 'package:immich_mobile/services/deep_link.service.dart'; -import 'package:immich_mobile/services/local_notification.service.dart'; -import 'package:immich_mobile/services/view_intent.service.dart'; -import 'package:immich_mobile/theme/dynamic_theme.dart'; -import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/services/deep_link.service.dart'; +import 'package:immich_mobile/services/local_notification.service.dart'; +import 'package:immich_mobile/theme/dynamic_theme.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -142,13 +141,13 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { - case AppLifecycleState.resumed: - dPrint(() => "[APP STATE] resumed"); - ref.read(appStateProvider.notifier).handleAppResume(); - // Check for ACTION_VIEW intent when app resumes - unawaited(ref.read(viewIntentServiceProvider).checkViewIntent()); - unawaited(ref.read(viewIntentServiceProvider).flushPending()); - break; + case AppLifecycleState.resumed: + dPrint(() => "[APP STATE] resumed"); + ref.read(appStateProvider.notifier).handleAppResume(); + // Check for ACTION_VIEW intent when app resumes + unawaited(ref.read(viewIntentHandlerProvider).checkForViewIntent()); + unawaited(ref.read(viewIntentHandlerProvider).flushPending()); + break; case AppLifecycleState.inactive: dPrint(() => "[APP STATE] inactive"); ref.read(appStateProvider.notifier).handleAppInactivity(); @@ -242,12 +241,12 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ref.read(viewIntentHandlerProvider).init(); ref.read(shareIntentUploadProvider.notifier).init(); - _authSubscription = ref.listenManual(authProvider.select((state) => state.isAuthenticated), (_, isAuthenticated) { - if (isAuthenticated) { - unawaited(ref.read(viewIntentServiceProvider).flushPending()); - } - }, fireImmediately: true); - } + _authSubscription = ref.listenManual(authProvider.select((state) => state.isAuthenticated), (_, isAuthenticated) { + if (isAuthenticated) { + unawaited(ref.read(viewIntentHandlerProvider).flushPending()); + } + }, fireImmediately: true); + } @override void dispose() { diff --git a/mobile/lib/models/view_intent/view_intent_payload.extension.dart b/mobile/lib/models/view_intent/view_intent_payload.extension.dart new file mode 100644 index 0000000000000..89b3066237d0d --- /dev/null +++ b/mobile/lib/models/view_intent/view_intent_payload.extension.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:path/path.dart'; + +extension ViewIntentPayloadX on ViewIntentPayload { + File get file => File(path); + + String get fileName => basename(file.path); + + bool get isImage => type == ViewIntentType.image; + + bool get isVideo => type == ViewIntentType.video; + + AssetPlaybackStyle get playbackStyle { + if (isVideo) { + return AssetPlaybackStyle.video; + } + + final normalizedMimeType = mimeType.toLowerCase(); + if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') { + return AssetPlaybackStyle.imageAnimated; + } + + final normalizedPath = path.toLowerCase(); + if (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp')) { + return AssetPlaybackStyle.imageAnimated; + } + + return AssetPlaybackStyle.image; + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 8def3065b1a20..cc34e8cbd3e55 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index ebb42ed3dab51..8feb0553e8b50 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -20,7 +20,7 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart' import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; @@ -291,8 +291,9 @@ class _AssetPageState extends ConsumerState { required String? localFilePath, }) { final size = context.sizeData; - final imageProvider = localFilePath != null ? FileImage(File(localFilePath)) : getFullImageProvider( - asset, size: size); + final imageProvider = localFilePath != null + ? FileImage(File(localFilePath)) + : getFullImageProvider(asset, size: size); if (asset.isImage && !isPlayingMotionVideo) { return PhotoView( @@ -347,11 +348,7 @@ class _AssetPageState extends ConsumerState { asset: asset, localFilePath: localFilePath, isCurrent: isCurrent, - image: Image( - image: imageProvider, - fit: BoxFit.contain, - alignment: Alignment.center, - ), + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), ), ); } diff --git a/mobile/lib/providers/asset_viewer/view_intent_file_path.provider.dart b/mobile/lib/providers/view_intent/view_intent_file_path.provider.dart similarity index 100% rename from mobile/lib/providers/asset_viewer/view_intent_file_path.provider.dart rename to mobile/lib/providers/view_intent/view_intent_file_path.provider.dart diff --git a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart similarity index 75% rename from mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart rename to mobile/lib/providers/view_intent/view_intent_handler.provider.dart index f621342a28b7a..bca9e37831f3d 100644 --- a/mobile/lib/providers/asset_viewer/view_intent_handler.provider.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -5,7 +5,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; @@ -28,30 +29,31 @@ class ViewIntentHandler { final AppRouter _router; static final Logger _logger = Logger('ViewIntentHandler'); - const ViewIntentHandler( - this._ref, - this._viewIntentService, - this._viewIntentAssetResolver, - this._router, - ); + const ViewIntentHandler(this._ref, this._viewIntentService, this._viewIntentAssetResolver, this._router); void init() { - _viewIntentService.onViewMedia = onViewMedia; - unawaited(_viewIntentService.checkViewIntent()); - unawaited(_viewIntentService.flushPending()); + unawaited(checkForViewIntent()); + unawaited(flushPending()); } - Future onViewMedia(List attachments) async { - if (attachments.isEmpty) { - return; + Future checkForViewIntent() async { + final attachment = await _viewIntentService.consumeViewIntent(); + if (attachment != null) { + await handle(attachment); + } + } + + Future flushPending() async { + final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh(); + if (pendingAttachment != null) { + await handle(pendingAttachment); } - await handle(attachments.first); } Future handle(ViewIntentPayload attachment) async { _logger.info('handle attachment: $attachment'); if (!_ref.read(authProvider).isAuthenticated) { - _viewIntentService.defer(attachment); + _ref.read(viewIntentPendingProvider.notifier).defer(attachment); return; } diff --git a/mobile/lib/providers/view_intent/view_intent_pending.provider.dart b/mobile/lib/providers/view_intent/view_intent_pending.provider.dart new file mode 100644 index 0000000000000..c3f68eff7935b --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_pending.provider.dart @@ -0,0 +1,39 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; + +final viewIntentNowProvider = Provider((ref) => DateTime.now); + +final viewIntentPendingProvider = NotifierProvider( + ViewIntentPendingNotifier.new, +); + +class ViewIntentPendingNotifier extends Notifier { + static const _ttl = Duration(minutes: 10); + + DateTime? _deferredAt; + + @override + ViewIntentPayload? build() => null; + + void defer(ViewIntentPayload attachment) { + _deferredAt = ref.read(viewIntentNowProvider)(); + state = attachment; + } + + ViewIntentPayload? takeIfFresh() { + final attachment = state; + final deferredAt = _deferredAt; + state = null; + _deferredAt = null; + + if (attachment == null) { + return null; + } + + if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) { + return null; + } + + return attachment; + } +} diff --git a/mobile/lib/services/view_intent.service.dart b/mobile/lib/services/view_intent.service.dart index 2d85fba66181b..fcaeac41b5ac9 100644 --- a/mobile/lib/services/view_intent.service.dart +++ b/mobile/lib/services/view_intent.service.dart @@ -8,32 +8,19 @@ final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntent class ViewIntentService { final ViewIntentHostApi _viewIntentHostApi; - Future Function(List attachments)? onViewMedia; - ViewIntentPayload? _pendingAttachment; String? _managedTempFilePath; ViewIntentService(this._viewIntentHostApi); - Future checkViewIntent() async { + Future consumeViewIntent() async { try { - final attachment = await _viewIntentHostApi.consumeViewIntent(); - if (attachment != null) { - final handler = onViewMedia; - if (handler == null) { - _pendingAttachment = attachment; - return; - } - await handler([attachment]); - } + return await _viewIntentHostApi.consumeViewIntent(); } catch (_) { // Ignore errors - view intent might not be present + return null; } } - void defer(ViewIntentPayload attachment) { - _pendingAttachment = attachment; - } - Future setManagedTempFilePath(String path) async { final previous = _managedTempFilePath; if (previous == path) { @@ -71,14 +58,4 @@ class ViewIntentService { bool _isManagedTempFile(String path) { return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache'; } - - Future flushPending() async { - final pendingAttachment = _pendingAttachment; - final handler = onViewMedia; - if (pendingAttachment == null || handler == null) { - return; - } - _pendingAttachment = null; - await handler([pendingAttachment]); - } } diff --git a/mobile/test/providers/view_intent/view_intent_pending_provider_test.dart b/mobile/test/providers/view_intent/view_intent_pending_provider_test.dart new file mode 100644 index 0000000000000..62c75921d700e --- /dev/null +++ b/mobile/test/providers/view_intent/view_intent_pending_provider_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart'; + +void main() { + late DateTime now; + late ProviderContainer container; + + final attachment = ViewIntentPayload( + path: '/tmp/file.jpg', + type: ViewIntentType.image, + mimeType: 'image/jpeg', + localAssetId: '42', + ); + + setUp(() { + now = DateTime(2026, 4, 17, 12); + container = ProviderContainer( + overrides: [viewIntentNowProvider.overrideWithValue(() => now)], + ); + addTearDown(container.dispose); + }); + + test('defer stores pending attachment', () { + container.read(viewIntentPendingProvider.notifier).defer(attachment); + + expect(container.read(viewIntentPendingProvider), attachment); + }); + + test('takeIfFresh returns pending attachment once', () { + container.read(viewIntentPendingProvider.notifier).defer(attachment); + + final first = container.read(viewIntentPendingProvider.notifier).takeIfFresh(); + final second = container.read(viewIntentPendingProvider.notifier).takeIfFresh(); + + expect(first, attachment); + expect(second, isNull); + }); + + test('takeIfFresh drops expired attachment', () { + container.read(viewIntentPendingProvider.notifier).defer(attachment); + now = now.add(const Duration(minutes: 11)); + + final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh(); + + expect(result, isNull); + expect(container.read(viewIntentPendingProvider), isNull); + }); + + test('newer deferred attachment replaces older one', () { + final newerAttachment = ViewIntentPayload( + path: '/tmp/file-2.jpg', + type: ViewIntentType.image, + mimeType: 'image/jpeg', + localAssetId: '43', + ); + + container.read(viewIntentPendingProvider.notifier).defer(attachment); + container.read(viewIntentPendingProvider.notifier).defer(newerAttachment); + + final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh(); + + expect(result, newerAttachment); + }); +} diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index e3cdaef15357e..630478eb3a0be 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -27,84 +27,30 @@ void main() { clearInteractions(hostApi); }); - test('checkViewIntent does nothing when no attachment', () async { + test('consumeViewIntent returns null when no attachment', () async { when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null); - var called = 0; - service.onViewMedia = (_) async { - called++; - }; + final result = await service.consumeViewIntent(); - await service.checkViewIntent(); - - expect(called, 0); + expect(result, isNull); verify(() => hostApi.consumeViewIntent()).called(1); }); - test('checkViewIntent calls handler immediately when handler is set', () async { + test('consumeViewIntent returns attachment when present', () async { when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment); - List? received; - service.onViewMedia = (attachments) async { - received = attachments; - }; - - await service.checkViewIntent(); + final result = await service.consumeViewIntent(); - expect(received, isNotNull); - expect(received, hasLength(1)); - expect(received!.first, attachment); + expect(result, attachment); verify(() => hostApi.consumeViewIntent()).called(1); }); - test('checkViewIntent stores pending attachment when handler is not set', () async { - when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment); - - await service.checkViewIntent(); - - List? received; - service.onViewMedia = (attachments) async { - received = attachments; - }; - await service.flushPending(); - - expect(received, isNotNull); - expect(received, hasLength(1)); - expect(received!.first, attachment); - }); - - test('defer + flushPending does nothing without handler, then flushes once when handler appears', () async { - service.defer(attachment); - - // No handler yet: should keep pending. - await service.flushPending(); - - var called = 0; - List? received; - service.onViewMedia = (attachments) async { - called++; - received = attachments; - }; - - await service.flushPending(); - await service.flushPending(); - - expect(called, 1); - expect(received, hasLength(1)); - expect(received!.first, attachment); - }); - - test('checkViewIntent swallows host api errors', () async { + test('consumeViewIntent swallows host api errors', () async { when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom')); - var called = 0; - service.onViewMedia = (_) async { - called++; - }; - - await service.checkViewIntent(); + final result = await service.consumeViewIntent(); - expect(called, 0); + expect(result, isNull); verify(() => hostApi.consumeViewIntent()).called(1); }); From 66a3aa27b5094a1cdfae8f0914198c6703c1f952 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 17 Apr 2026 18:40:20 +0300 Subject: [PATCH 12/14] refactor(mobile): resolve merge conflicts use NativeSyncApi for hash files instead method from removed BackgroundServicePlugin.kt --- .../immich/BackgroundServicePlugin.kt | 380 ------------------ .../app/alextran/immich/MainActivity.kt | 1 - .../app/alextran/immich/sync/Messages.g.kt | 21 + .../alextran/immich/sync/MessagesImplBase.kt | 80 +++- mobile/ios/Runner/Sync/Messages.g.swift | 20 + mobile/ios/Runner/Sync/MessagesImpl.swift | 7 + mobile/lib/main.dart | 1 - mobile/lib/platform/native_sync_api.g.dart | 240 +++++++---- .../view_intent_asset_resolver.service.dart | 12 +- mobile/pigeon/native_sync_api.dart | 4 + 10 files changed, 284 insertions(+), 482 deletions(-) delete mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt deleted file mode 100644 index d3d8bdd35d72c..0000000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ /dev/null @@ -1,380 +0,0 @@ -package app.alextran.immich - -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 android.provider.Settings -import android.util.Log -import androidx.annotation.RequiresApi -import app.alextran.immich.media.MediaStoreUtils -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.embedding.engine.plugins.activity.ActivityAware -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.PluginRegistry -import java.security.MessageDigest -import java.io.FileInputStream -import kotlinx.coroutines.* -import androidx.core.net.toUri - -/** - * Android plugin for Dart `BackgroundService` and file trash operations - */ -class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener { - - private var methodChannel: MethodChannel? = null - private var fileTrashChannel: MethodChannel? = null - private var context: Context? = null - private var pendingResult: Result? = null - private val permissionRequestCode = 1001 - private val trashRequestCode = 1002 - private var activityBinding: ActivityPluginBinding? = null - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) - } - - private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { - context = ctx - methodChannel = MethodChannel(messenger, "immich/foregroundChannel") - methodChannel?.setMethodCallHandler(this) - - // Add file trash channel - fileTrashChannel = MethodChannel(messenger, "file_trash") - fileTrashChannel?.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onDetachedFromEngine() - } - - private fun onDetachedFromEngine() { - methodChannel?.setMethodCallHandler(null) - methodChannel = null - fileTrashChannel?.setMethodCallHandler(null) - fileTrashChannel = null - } - - override fun onMethodCall(call: MethodCall, result: Result) { - val ctx = context!! - when (call.method) { - // Existing BackgroundService methods - "enable" -> { - val args = call.arguments>()!! - ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) - .edit() - .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) - .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args[0] as Long) - .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args[1] as String) - .apply() - ContentObserverWorker.enable(ctx, immediate = args[2] as Boolean) - result.success(true) - } - - "configure" -> { - val args = call.arguments>()!! - val requireUnmeteredNetwork = args[0] as Boolean - val requireCharging = args[1] as Boolean - val triggerUpdateDelay = (args[2] as Number).toLong() - val triggerMaxDelay = (args[3] as Number).toLong() - ContentObserverWorker.configureWork( - ctx, - requireUnmeteredNetwork, - requireCharging, - triggerUpdateDelay, - triggerMaxDelay - ) - result.success(true) - } - - "disable" -> { - ContentObserverWorker.disable(ctx) - BackupWorker.stopWork(ctx) - result.success(true) - } - - "isEnabled" -> { - result.success(ContentObserverWorker.isEnabled(ctx)) - } - - "isIgnoringBatteryOptimizations" -> { - result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx)) - } - - "digestFiles" -> { - val args = call.arguments>()!! - GlobalScope.launch(Dispatchers.IO) { - val buf = ByteArray(BUFFER_SIZE) - val digest: MessageDigest = MessageDigest.getInstance("SHA-1") - val hashes = arrayOfNulls(args.size) - for (i in args.indices) { - val path = args[i] - var len = 0 - try { - val file = FileInputStream(path) - file.use { assetFile -> - while (true) { - len = assetFile.read(buf) - if (len != BUFFER_SIZE) break - digest.update(buf) - } - } - digest.update(buf, 0, len) - hashes[i] = digest.digest() - } catch (e: Exception) { - // skip this file - Log.w(TAG, "Failed to hash file ${args[i]}: $e") - } - } - result.success(hashes.asList()) - } - } - - // File Trash methods moved from MainActivity - "moveToTrash" -> { - val mediaUrls = call.argument>("mediaUrls") - if (mediaUrls != null) { - if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { - moveToTrash(mediaUrls, result) - } else { - result.error("PERMISSION_DENIED", "Media permission required", null) - } - } else { - result.error("INVALID_NAME", "The mediaUrls is not specified.", null) - } - } - - "restoreFromTrash" -> { - val fileName = call.argument("fileName") - val type = call.argument("type") - val mediaId = call.argument("mediaId") - if (fileName != null && type != null) { - if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { - restoreFromTrash(fileName, type, result) - } else { - result.error("PERMISSION_DENIED", "Media permission required", null) - } - } else - if (mediaId != null && type != null) { - if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { - restoreFromTrashById(mediaId, type, result) - } else { - result.error("PERMISSION_DENIED", "Media permission required", null) - } - } else { - result.error("INVALID_PARAMS", "Required params are not specified.", null) - } - } - - "requestManageMediaPermission" -> { - if (!hasManageMediaPermission()) { - requestManageMediaPermission(result) - } else { - Log.e("Manage storage permission", "Permission already granted") - result.success(true) - } - } - - "hasManageMediaPermission" -> { - if (hasManageMediaPermission()) { - Log.i("Manage storage permission", "Permission already granted") - result.success(true) - } else { - result.success(false) - } - } - - "manageMediaPermission" -> requestManageMediaPermission(result) - - else -> result.notImplemented() - } - } - - private fun hasManageMediaPermission(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaStore.canManageMedia(context!!); - } else { - false - } - } - - private fun requestManageMediaPermission(result: Result) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - pendingResult = result // Store the result callback - val activity = activityBinding?.activity ?: return - - val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA) - intent.data = "package:${activity.packageName}".toUri() - activity.startActivityForResult(intent, permissionRequestCode) - } else { - result.success(false) - } - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun moveToTrash(mediaUrls: List, result: Result) { - val urisToTrash = mediaUrls.map { it.toUri() } - if (urisToTrash.isEmpty()) { - result.error("INVALID_ARGS", "No valid URIs provided", null) - return - } - - toggleTrash(urisToTrash, true, result); - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun restoreFromTrash(name: String, type: Int, result: Result) { - val uri = getTrashedFileUri(name, type) - if (uri == null) { - Log.e("TrashError", "Asset Uri cannot be found obtained") - result.error("TrashError", "Asset Uri cannot be found obtained", null) - return - } - Log.e("FILE_URI", uri.toString()) - uri.let { toggleTrash(listOf(it), false, result) } - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun restoreFromTrashById(mediaId: String, type: Int, result: Result) { - val id = mediaId.toLongOrNull() - if (id == null) { - result.error("INVALID_ID", "The file id is not a valid number: $mediaId", null) - return - } - if (!isInTrash(id)) { - result.error("TrashNotFound", "Item with id=$id not found in trash", null) - return - } - - val uri = ContentUris.withAppendedId(MediaStoreUtils.contentUriForAssetType(type), id) - - try { - Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)") - restoreUris(listOf(uri), result) - } catch (e: Exception) { - Log.w(TAG, "restoreFromTrashById failed", e) - } - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) { - val activity = activityBinding?.activity - val contentResolver = context?.contentResolver - if (activity == null || contentResolver == null) { - result.error("TrashError", "Activity or ContentResolver not available", null) - return - } - - try { - val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) - pendingResult = result // Store for onActivityResult - activity.startIntentSenderForResult( - pendingIntent.intentSender, - trashRequestCode, - null, 0, 0, 0 - ) - } catch (e: Exception) { - Log.e("TrashError", "Error creating or starting trash request", e) - result.error("TrashError", "Error creating or starting trash request", null) - } - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun getTrashedFileUri(fileName: String, type: Int): Uri? { - val contentResolver = context?.contentResolver ?: return null - val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) - val projection = arrayOf(MediaStore.Files.FileColumns._ID) - - val queryArgs = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?" - ) - putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName)) - putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) - } - - contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) - return ContentUris.withAppendedId(MediaStoreUtils.contentUriForAssetType(type), id) - } - } - return null - } - - // ActivityAware implementation - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activityBinding = binding - binding.addActivityResultListener(this) - } - - override fun onDetachedFromActivityForConfigChanges() { - activityBinding?.removeActivityResultListener(this) - activityBinding = null - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activityBinding = binding - binding.addActivityResultListener(this) - } - - override fun onDetachedFromActivity() { - activityBinding?.removeActivityResultListener(this) - activityBinding = null - } - - // ActivityResultListener implementation - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - if (requestCode == permissionRequestCode) { - val granted = hasManageMediaPermission() - pendingResult?.success(granted) - pendingResult = null - return true - } - - if (requestCode == trashRequestCode) { - val approved = resultCode == Activity.RESULT_OK - pendingResult?.success(approved) - pendingResult = null - return true - } - return false - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun isInTrash(id: Long): Boolean { - val contentResolver = context?.contentResolver ?: return false - 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 contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null) - ?.use { it.moveToFirst() } == true - } - - @RequiresApi(Build.VERSION_CODES.R) - private fun restoreUris(uris: List, result: Result) { - if (uris.isEmpty()) { - result.error("TrashError", "No URIs to restore", null) - return - } - Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}") - toggleTrash(uris, false, result) - } -} - -private const val TAG = "BackgroundServicePlugin" -private const val BUFFER_SIZE = 2 * 1024 * 1024 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 c56dad5f62c1b..9205697a53d04 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 @@ -58,7 +58,6 @@ class MainActivity : FlutterFragmentActivity() { BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) - flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(ViewIntentPlugin()) flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(nativeSyncApiImpl) 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 29c197c2b6313..0067fb62728ba 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 @@ -383,6 +383,7 @@ interface NativeSyncApi { fun getAssetsCountSince(albumId: String, timestamp: Long): Long fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List fun hashAssets(assetIds: List, allowNetworkAccess: Boolean, callback: (Result>) -> Unit) + fun hashFiles(paths: List, callback: (Result>) -> Unit) fun cancelHashing() fun getTrashedAssets(): Map> fun getCloudIdForAssetIds(assetIds: List): List @@ -548,6 +549,26 @@ interface NativeSyncApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$separatedMessageChannelSuffix", codec, taskQueue) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pathsArg = args[0] as List + api.hashFiles(pathsArg) { 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.cancelHashing$separatedMessageChannelSuffix", codec) 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 eea66db2f6e72..8d099aff9b9c8 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 @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.io.File +import java.io.FileInputStream +import java.io.InputStream import java.security.MessageDigest import kotlin.coroutines.cancellation.CancellationException @@ -417,6 +419,44 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } } + fun hashFiles( + paths: List, + callback: (Result>) -> Unit + ) { + if (paths.isEmpty()) { + completeWhenActive(callback, Result.success(emptyList())) + return + } + + hashTask?.cancel() + hashTask = CoroutineScope(Dispatchers.IO).launch { + try { + val results = paths.map { path -> + async { + hashSemaphore.withPermit { + ensureActive() + hashFile(path) + } + } + }.awaitAll() + + completeWhenActive(callback, Result.success(results)) + } catch (e: CancellationException) { + completeWhenActive( + callback, Result.failure( + FlutterError( + HASHING_CANCELLED_CODE, + "Hashing operation was cancelled", + null + ) + ) + ) + } catch (e: Exception) { + completeWhenActive(callback, Result.failure(e)) + } + } + } + private suspend fun hashAsset(assetId: String): HashResult { return try { val assetUri = ContentUris.withAppendedId( @@ -424,17 +464,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { assetId.toLong() ) - val digest = MessageDigest.getInstance("SHA-1") - ctx.contentResolver.openInputStream(assetUri)?.use { inputStream -> - var bytesRead: Int - val buffer = ByteArray(HASH_BUFFER_SIZE) - while (inputStream.read(buffer).also { bytesRead = it } > 0) { - currentCoroutineContext().ensureActive() - digest.update(buffer, 0, bytesRead) - } + val hashString = ctx.contentResolver.openInputStream(assetUri)?.use { inputStream -> + hashInputStream(inputStream) } ?: return HashResult(assetId, "Cannot open input stream for asset", null) - val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP) HashResult(assetId, null, hashString) } catch (e: SecurityException) { HashResult(assetId, "Permission denied accessing asset: ${e.message}", null) @@ -443,6 +476,35 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } } + private suspend fun hashFile(path: String): HashResult { + return try { + val file = File(path) + if (!file.exists()) { + return HashResult(path, "File does not exist", null) + } + + val hashString = FileInputStream(file).use { inputStream -> + hashInputStream(inputStream) + } + HashResult(path, null, hashString) + } catch (e: SecurityException) { + HashResult(path, "Permission denied accessing file: ${e.message}", null) + } catch (e: Exception) { + HashResult(path, "Failed to hash file: ${e.message}", null) + } + } + + private suspend fun hashInputStream(inputStream: InputStream): String { + val digest = MessageDigest.getInstance("SHA-1") + var bytesRead: Int + val buffer = ByteArray(HASH_BUFFER_SIZE) + while (inputStream.read(buffer).also { bytesRead = it } > 0) { + currentCoroutineContext().ensureActive() + digest.update(buffer, 0, bytesRead) + } + return Base64.encodeToString(digest.digest(), Base64.NO_WRAP) + } + fun cancelHashing() { hashTask?.cancel() hashTask = null diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index 6bba25d94bbcd..6750592a99f1f 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -435,6 +435,7 @@ protocol NativeSyncApi { func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) + func hashFiles(paths: [String], completion: @escaping (Result<[HashResult], Error>) -> Void) func cancelHashing() throws func getTrashedAssets() throws -> [String: [PlatformAsset]] func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] @@ -593,6 +594,25 @@ class NativeSyncApiSetup { } else { hashAssetsChannel.setMessageHandler(nil) } + let hashFilesChannel = taskQueue == nil + ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) + if let api = api { + hashFilesChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let pathsArg = args[0] as! [String] + api.hashFiles(paths: pathsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + hashFilesChannel.setMessageHandler(nil) + } let cancelHashingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { cancelHashingChannel.setMessageHandler { _, reply in diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 8022fb06d2077..85172759dfb19 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -318,6 +318,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { } } } + + func hashFiles(paths: [String], completion: @escaping (Result<[HashResult], Error>) -> Void) { + let results = paths.map { path in + HashResult(assetId: path, error: "Not implemented on iOS", hash: nil) + } + completeWhenActive(for: completion, with: .success(results)) + } func cancelHashing() { hashTask?.cancel() diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c55b9ea83c154..3f11da3c4b991 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -25,7 +25,6 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 6681912c2f8fa..841d26634dc14 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -14,22 +14,29 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } - bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { - return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && - a.entries.every( - (MapEntry entry) => - (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), - ); + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); } return a == b; } -enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } + +enum PlatformAssetPlaybackStyle { + unknown, + image, + video, + imageAnimated, + livePhoto, + videoLooping, +} class PlatformAsset { PlatformAsset({ @@ -97,8 +104,7 @@ class PlatformAsset { } Object encode() { - return _toList(); - } + return _toList(); } static PlatformAsset decode(Object result) { result as List; @@ -134,7 +140,8 @@ class PlatformAsset { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } class PlatformAlbum { @@ -157,12 +164,17 @@ class PlatformAlbum { int assetCount; List _toList() { - return [id, name, updatedAt, isCloud, assetCount]; + return [ + id, + name, + updatedAt, + isCloud, + assetCount, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static PlatformAlbum decode(Object result) { result as List; @@ -189,11 +201,17 @@ class PlatformAlbum { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } class SyncDelta { - SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums}); + SyncDelta({ + required this.hasChanges, + required this.updates, + required this.deletes, + required this.assetAlbums, + }); bool hasChanges; @@ -204,12 +222,16 @@ class SyncDelta { Map> assetAlbums; List _toList() { - return [hasChanges, updates, deletes, assetAlbums]; + return [ + hasChanges, + updates, + deletes, + assetAlbums, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static SyncDelta decode(Object result) { result as List; @@ -235,11 +257,16 @@ class SyncDelta { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } class HashResult { - HashResult({required this.assetId, this.error, this.hash}); + HashResult({ + required this.assetId, + this.error, + this.hash, + }); String assetId; @@ -248,16 +275,23 @@ class HashResult { String? hash; List _toList() { - return [assetId, error, hash]; + return [ + assetId, + error, + hash, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static HashResult decode(Object result) { result as List; - return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?); + return HashResult( + assetId: result[0]! as String, + error: result[1] as String?, + hash: result[2] as String?, + ); } @override @@ -274,11 +308,16 @@ class HashResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } class CloudIdResult { - CloudIdResult({required this.assetId, this.error, this.cloudId}); + CloudIdResult({ + required this.assetId, + this.error, + this.cloudId, + }); String assetId; @@ -287,16 +326,23 @@ class CloudIdResult { String? cloudId; List _toList() { - return [assetId, error, cloudId]; + return [ + assetId, + error, + cloudId, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static CloudIdResult decode(Object result) { result as List; - return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?); + return CloudIdResult( + assetId: result[0]! as String, + error: result[1] as String?, + cloudId: result[2] as String?, + ); } @override @@ -313,9 +359,11 @@ class CloudIdResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -323,22 +371,22 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is PlatformAssetPlaybackStyle) { + } else if (value is PlatformAssetPlaybackStyle) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is PlatformAsset) { + } else if (value is PlatformAsset) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is PlatformAlbum) { + } else if (value is PlatformAlbum) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is SyncDelta) { + } else if (value is SyncDelta) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is HashResult) { + } else if (value is HashResult) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is CloudIdResult) { + } else if (value is CloudIdResult) { buffer.putUint8(134); writeValue(buffer, value.encode()); } else { @@ -349,18 +397,18 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: final int? value = readValue(buffer) as int?; return value == null ? null : PlatformAssetPlaybackStyle.values[value]; - case 130: + case 130: return PlatformAsset.decode(readValue(buffer)!); - case 131: + case 131: return PlatformAlbum.decode(readValue(buffer)!); - case 132: + case 132: return SyncDelta.decode(readValue(buffer)!); - case 133: + case 133: return HashResult.decode(readValue(buffer)!); - case 134: + case 134: return CloudIdResult.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -373,8 +421,8 @@ class NativeSyncApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -382,15 +430,15 @@ class NativeSyncApi { final String pigeonVar_messageChannelSuffix; Future shouldFullSync() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -410,15 +458,15 @@ class NativeSyncApi { } Future getMediaChanges() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -438,15 +486,15 @@ class NativeSyncApi { } Future checkpointSync() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -461,15 +509,15 @@ class NativeSyncApi { } Future clearSyncCheckpoint() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -484,15 +532,15 @@ class NativeSyncApi { } Future> getAssetIdsForAlbum(String albumId) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -512,15 +560,15 @@ class NativeSyncApi { } Future> getAlbums() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -540,15 +588,15 @@ class NativeSyncApi { } Future getAssetsCountSince(String albumId, int timestamp) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId, timestamp]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -568,15 +616,15 @@ class NativeSyncApi { } Future> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId, updatedTimeCond]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -596,15 +644,43 @@ class NativeSyncApi { } Future> hashAssets(List assetIds, {bool allowNetworkAccess = false}) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([assetIds, allowNetworkAccess]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } + + Future> hashFiles(List paths) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([paths]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -624,15 +700,15 @@ class NativeSyncApi { } Future cancelHashing() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -647,15 +723,15 @@ class NativeSyncApi { } Future>> getTrashedAssets() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -675,15 +751,15 @@ class NativeSyncApi { } Future> getCloudIdForAssetIds(List assetIds) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([assetIds]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index f3e2e269e7762..522d7ed7965b3 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -12,7 +11,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/background.service.dart'; import 'package:logging/logging.dart'; class ViewIntentResolvedAsset { @@ -34,7 +32,6 @@ final viewIntentAssetResolverProvider = Provider( ref, ref.read(localAssetRepository), ref.read(nativeSyncApiProvider), - ref.read(backgroundServiceProvider), ref.read(timelineFactoryProvider), ), ); @@ -43,7 +40,6 @@ class ViewIntentAssetResolver { final Ref _ref; final DriftLocalAssetRepository _localAssetRepository; final NativeSyncApi _nativeSyncApi; - final BackgroundService _backgroundService; final TimelineFactory _timelineFactory; static final Logger _logger = Logger('ViewIntentAssetResolver'); @@ -51,7 +47,6 @@ class ViewIntentAssetResolver { this._ref, this._localAssetRepository, this._nativeSyncApi, - this._backgroundService, this._timelineFactory, ); @@ -200,12 +195,11 @@ class ViewIntentAssetResolver { Future _computeChecksumForPath(String path) async { try { - final hashes = await _backgroundService.digestFiles([path]); - final hash = hashes == null || hashes.isEmpty ? null : hashes.first; - if (hash == null || hash.length != 20) { + final hashResults = await _nativeSyncApi.hashFiles([path]); + if (hashResults.isEmpty) { return null; } - return base64.encode(hash); + return hashResults.first.hash; } catch (_) { return null; } diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index cd55addd9942c..83dddbab14397 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -137,6 +137,10 @@ abstract class NativeSyncApi { @TaskQueue(type: TaskQueueType.serialBackgroundThread) List hashAssets(List assetIds, {bool allowNetworkAccess = false}); + @async + @TaskQueue(type: TaskQueueType.serialBackgroundThread) + List hashFiles(List paths); + void cancelHashing(); @TaskQueue(type: TaskQueueType.serialBackgroundThread) From 2775a09dc52faa65f6e084e5d6d0a2276b9c0754 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 17 Apr 2026 18:47:46 +0300 Subject: [PATCH 13/14] style(mobile): format files --- mobile/lib/platform/native_sync_api.g.dart | 218 ++++++++------------- mobile/lib/platform/view_intent_api.g.dart | 58 +++--- 2 files changed, 107 insertions(+), 169 deletions(-) diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 841d26634dc14..04098bb986296 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -14,29 +14,22 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } + bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { - return a.length == b.length && - a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && a.entries.every((MapEntry entry) => - (b as Map).containsKey(entry.key) && - _deepEquals(entry.value, b[entry.key])); + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), + ); } return a == b; } - -enum PlatformAssetPlaybackStyle { - unknown, - image, - video, - imageAnimated, - livePhoto, - videoLooping, -} +enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } class PlatformAsset { PlatformAsset({ @@ -104,7 +97,8 @@ class PlatformAsset { } Object encode() { - return _toList(); } + return _toList(); + } static PlatformAsset decode(Object result) { result as List; @@ -140,8 +134,7 @@ class PlatformAsset { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class PlatformAlbum { @@ -164,17 +157,12 @@ class PlatformAlbum { int assetCount; List _toList() { - return [ - id, - name, - updatedAt, - isCloud, - assetCount, - ]; + return [id, name, updatedAt, isCloud, assetCount]; } Object encode() { - return _toList(); } + return _toList(); + } static PlatformAlbum decode(Object result) { result as List; @@ -201,17 +189,11 @@ class PlatformAlbum { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class SyncDelta { - SyncDelta({ - required this.hasChanges, - required this.updates, - required this.deletes, - required this.assetAlbums, - }); + SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums}); bool hasChanges; @@ -222,16 +204,12 @@ class SyncDelta { Map> assetAlbums; List _toList() { - return [ - hasChanges, - updates, - deletes, - assetAlbums, - ]; + return [hasChanges, updates, deletes, assetAlbums]; } Object encode() { - return _toList(); } + return _toList(); + } static SyncDelta decode(Object result) { result as List; @@ -257,16 +235,11 @@ class SyncDelta { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class HashResult { - HashResult({ - required this.assetId, - this.error, - this.hash, - }); + HashResult({required this.assetId, this.error, this.hash}); String assetId; @@ -275,23 +248,16 @@ class HashResult { String? hash; List _toList() { - return [ - assetId, - error, - hash, - ]; + return [assetId, error, hash]; } Object encode() { - return _toList(); } + return _toList(); + } static HashResult decode(Object result) { result as List; - return HashResult( - assetId: result[0]! as String, - error: result[1] as String?, - hash: result[2] as String?, - ); + return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?); } @override @@ -308,16 +274,11 @@ class HashResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } class CloudIdResult { - CloudIdResult({ - required this.assetId, - this.error, - this.cloudId, - }); + CloudIdResult({required this.assetId, this.error, this.cloudId}); String assetId; @@ -326,23 +287,16 @@ class CloudIdResult { String? cloudId; List _toList() { - return [ - assetId, - error, - cloudId, - ]; + return [assetId, error, cloudId]; } Object encode() { - return _toList(); } + return _toList(); + } static CloudIdResult decode(Object result) { result as List; - return CloudIdResult( - assetId: result[0]! as String, - error: result[1] as String?, - cloudId: result[2] as String?, - ); + return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?); } @override @@ -359,11 +313,9 @@ class CloudIdResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } - class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -371,22 +323,22 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is PlatformAssetPlaybackStyle) { + } else if (value is PlatformAssetPlaybackStyle) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is PlatformAsset) { + } else if (value is PlatformAsset) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is PlatformAlbum) { + } else if (value is PlatformAlbum) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is SyncDelta) { + } else if (value is SyncDelta) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is HashResult) { + } else if (value is HashResult) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is CloudIdResult) { + } else if (value is CloudIdResult) { buffer.putUint8(134); writeValue(buffer, value.encode()); } else { @@ -397,18 +349,18 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: final int? value = readValue(buffer) as int?; return value == null ? null : PlatformAssetPlaybackStyle.values[value]; - case 130: + case 130: return PlatformAsset.decode(readValue(buffer)!); - case 131: + case 131: return PlatformAlbum.decode(readValue(buffer)!); - case 132: + case 132: return SyncDelta.decode(readValue(buffer)!); - case 133: + case 133: return HashResult.decode(readValue(buffer)!); - case 134: + case 134: return CloudIdResult.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -421,8 +373,8 @@ class NativeSyncApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -430,15 +382,15 @@ class NativeSyncApi { final String pigeonVar_messageChannelSuffix; Future shouldFullSync() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -458,15 +410,15 @@ class NativeSyncApi { } Future getMediaChanges() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -486,15 +438,15 @@ class NativeSyncApi { } Future checkpointSync() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -509,15 +461,15 @@ class NativeSyncApi { } Future clearSyncCheckpoint() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -532,15 +484,15 @@ class NativeSyncApi { } Future> getAssetIdsForAlbum(String albumId) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -560,15 +512,15 @@ class NativeSyncApi { } Future> getAlbums() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -588,15 +540,15 @@ class NativeSyncApi { } Future getAssetsCountSince(String albumId, int timestamp) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId, timestamp]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -616,15 +568,15 @@ class NativeSyncApi { } Future> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([albumId, updatedTimeCond]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -644,15 +596,15 @@ class NativeSyncApi { } Future> hashAssets(List assetIds, {bool allowNetworkAccess = false}) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([assetIds, allowNetworkAccess]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -672,15 +624,15 @@ class NativeSyncApi { } Future> hashFiles(List paths) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([paths]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -700,15 +652,15 @@ class NativeSyncApi { } Future cancelHashing() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -723,15 +675,15 @@ class NativeSyncApi { } Future>> getTrashedAssets() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -751,15 +703,15 @@ class NativeSyncApi { } Future> getCloudIdForAssetIds(List assetIds) async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([assetIds]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/mobile/lib/platform/view_intent_api.g.dart b/mobile/lib/platform/view_intent_api.g.dart index 4190d2cc1cf7e..bcc1f3a8eef2f 100644 --- a/mobile/lib/platform/view_intent_api.g.dart +++ b/mobile/lib/platform/view_intent_api.g.dart @@ -14,33 +14,25 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } + bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { - return a.length == b.length && - a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && a.entries.every((MapEntry entry) => - (b as Map).containsKey(entry.key) && - _deepEquals(entry.value, b[entry.key])); + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), + ); } return a == b; } - -enum ViewIntentType { - image, - video, -} +enum ViewIntentType { image, video } class ViewIntentPayload { - ViewIntentPayload({ - required this.path, - required this.type, - required this.mimeType, - this.localAssetId, - }); + ViewIntentPayload({required this.path, required this.type, required this.mimeType, this.localAssetId}); String path; @@ -51,16 +43,12 @@ class ViewIntentPayload { String? localAssetId; List _toList() { - return [ - path, - type, - mimeType, - localAssetId, - ]; + return [path, type, mimeType, localAssetId]; } Object encode() { - return _toList(); } + return _toList(); + } static ViewIntentPayload decode(Object result) { result as List; @@ -86,11 +74,9 @@ class ViewIntentPayload { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()) -; + int get hashCode => Object.hashAll(_toList()); } - class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -98,10 +84,10 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is ViewIntentType) { + } else if (value is ViewIntentType) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is ViewIntentPayload) { + } else if (value is ViewIntentPayload) { buffer.putUint8(130); writeValue(buffer, value.encode()); } else { @@ -112,10 +98,10 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: final int? value = readValue(buffer) as int?; return value == null ? null : ViewIntentType.values[value]; - case 130: + case 130: return ViewIntentPayload.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -128,8 +114,8 @@ class ViewIntentHostApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -137,15 +123,15 @@ class ViewIntentHostApi { final String pigeonVar_messageChannelSuffix; Future consumeViewIntent() async { - final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { From dc15af4e69ac7afee332d258c36c609f236655e1 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 17 Apr 2026 19:01:17 +0300 Subject: [PATCH 14/14] style(mobile): format files #2 --- .../lib/services/view_intent_asset_resolver.service.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 522d7ed7965b3..a345ee58985fd 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -43,12 +43,7 @@ class ViewIntentAssetResolver { final TimelineFactory _timelineFactory; static final Logger _logger = Logger('ViewIntentAssetResolver'); - const ViewIntentAssetResolver( - this._ref, - this._localAssetRepository, - this._nativeSyncApi, - this._timelineFactory, - ); + const ViewIntentAssetResolver(this._ref, this._localAssetRepository, this._nativeSyncApi, this._timelineFactory); Future resolve(ViewIntentPayload attachment) async { final localAssetId = attachment.localAssetId;