diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 436d8c492d..1b8d2a97fb 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -89,6 +89,20 @@ + + + + + + + + + + + + + + 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 b4cd705b05..fc9ab28fa2 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,6 +1,7 @@ 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 @@ -22,6 +23,7 @@ import app.alextran.immich.permission.PermissionApiImpl 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 @@ -31,6 +33,11 @@ class MainActivity : FlutterFragmentActivity() { registerPlugins(this, flutterEngine) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } + companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { HttpClientManager.initialize(ctx) @@ -55,6 +62,7 @@ class MainActivity : FlutterFragmentActivity() { BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) + flutterEngine.plugins.add(ViewIntentPlugin()) flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(nativeSyncApiImpl) flutterEngine.plugins.add(permissionApiImpl) 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 0000000000..1d5af15cb4 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt @@ -0,0 +1,292 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.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 doubleEquals(a: Double, b: Double): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN()) + } + + fun floatEquals(a: Float, b: Float): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN()) + } + + fun doubleHash(d: Double): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (d == 0.0) 0.0 else d + val bits = java.lang.Double.doubleToLongBits(normalized) + return (bits xor (bits ushr 32)).toInt() + } + + fun floatHash(f: Float): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (f == 0.0f) 0.0f else f + return java.lang.Float.floatToIntBits(normalized) + } + + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a === b) { + return true + } + if (a == null || b == null) { + return false + } + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!doubleEquals(a[i], b[i])) return false + } + return true + } + if (a is FloatArray && b is FloatArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!floatEquals(a[i], b[i])) return false + } + return true + } + if (a is Array<*> && b is Array<*>) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!deepEquals(a[i], b[i])) return false + } + return true + } + if (a is List<*> && b is List<*>) { + if (a.size != b.size) return false + val iterA = a.iterator() + val iterB = b.iterator() + while (iterA.hasNext() && iterB.hasNext()) { + if (!deepEquals(iterA.next(), iterB.next())) return false + } + return true + } + if (a is Map<*, *> && b is Map<*, *>) { + if (a.size != b.size) return false + for (entry in a) { + val key = entry.key + var found = false + for (bEntry in b) { + if (deepEquals(key, bEntry.key)) { + if (deepEquals(entry.value, bEntry.value)) { + found = true + break + } else { + return false + } + } + } + if (!found) return false + } + return true + } + if (a is Double && b is Double) { + return doubleEquals(a, b) + } + if (a is Float && b is Float) { + return floatEquals(a, b) + } + return a == b + } + + fun deepHash(value: Any?): Int { + return when (value) { + null -> 0 + is ByteArray -> value.contentHashCode() + is IntArray -> value.contentHashCode() + is LongArray -> value.contentHashCode() + is DoubleArray -> { + var result = 1 + for (item in value) { + result = 31 * result + doubleHash(item) + } + result + } + is FloatArray -> { + var result = 1 + for (item in value) { + result = 31 * result + floatHash(item) + } + result + } + is Array<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is List<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is Map<*, *> -> { + var result = 0 + for (entry in value) { + result += ((deepHash(entry.key) * 31) xor deepHash(entry.value)) + } + result + } + is Double -> doubleHash(value) + is Float -> floatHash(value) + else -> value.hashCode() + } + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : RuntimeException() + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ViewIntentPayload ( + val path: String? = null, + val mimeType: String, + val localAssetId: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ViewIntentPayload { + val path = pigeonVar_list[0] as String? + val mimeType = pigeonVar_list[1] as String + val localAssetId = pigeonVar_list[2] as String? + return ViewIntentPayload(path, mimeType, localAssetId) + } + } + fun toList(): List { + return listOf( + path, + mimeType, + localAssetId, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as ViewIntentPayload + return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path) + result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType) + result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId) + return result + } +} +private open class ViewIntentPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.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 ViewIntentPayload -> { + stream.write(129) + 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 0000000000..a1e1fea3dd --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -0,0 +1,201 @@ +package app.alextran.immich.viewintent + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.util.Log +import android.webkit.MimeTypeMap +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 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 unconsumedIntent: Intent? = null + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + 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) + ioScope.cancel() + context = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + unconsumedIntent = 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 { + unconsumedIntent = intent + return false + } + + override fun consumeViewIntent(callback: (Result) -> Unit) { + val context = context ?: run { + callback(Result.success(null)) + return + } + val intent = unconsumedIntent ?: 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 + } + + ioScope.launch { + try { + val mimeType = context.contentResolver.getType(uri) ?: intent.type + if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) { + callback(Result.success(null)) + return@launch + } + + val localAssetId = extractLocalAssetId(context, uri, mimeType) + val tempFilePath = if (localAssetId == null) { + copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run { + callback(Result.success(null)) + return@launch + } + } else { + null + } + val payload = ViewIntentPayload( + path = tempFilePath, + mimeType = mimeType, + localAssetId = localAssetId, + ) + consumeViewIntent(intent) + callback(Result.success(payload)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } + + private fun consumeViewIntent(currentIntent: Intent) { + unconsumedIntent = Intent(currentIntent).apply { + action = null + data = null + type = null + } + activity?.intent = unconsumedIntent + } + + private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? { + return tryExtractDocumentLocalAssetId(context, uri) + ?: tryParseContentUriId(uri) + ?: resolveLocalIdByNameAndSize(context, uri, mimeType) + } + + private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? { + return try { + if (!DocumentsContract.isDocumentUri(context, uri)) return null + val docId = DocumentsContract.getDocumentId(uri) + if (docId.isBlank() || docId.startsWith("raw:")) return null + docId.substringAfter(':', docId).toLongOrNull()?.toString() + } catch (e: Exception) { + Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e) + null + } + } + + private fun tryParseContentUriId(uri: Uri): String? { + val id = uri.lastPathSegment?.toLongOrNull() ?: return null + return if (id >= 0) id.toString() else null + } + + private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? { + return try { + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" } + val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir) + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } ?: return null + tempFile + } 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 -> return null + } + return try { + context.contentResolver + .query( + tableUri, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?", + arrayOf(displayName, size.toString()), + "${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/lib/main.dart b/mobile/lib/main.dart index cc5f131572..75f1c2221a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -24,6 +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/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; @@ -128,6 +129,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve case AppLifecycleState.resumed: dPrint(() => "[APP STATE] resumed"); ref.read(appStateProvider.notifier).handleAppResume(); + unawaited(ref.read(viewIntentHandlerProvider).onAppResumed()); break; case AppLifecycleState.inactive: dPrint(() => "[APP STATE] inactive"); @@ -233,6 +235,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve } }); + ref.read(viewIntentHandlerProvider).init(); ref.read(shareIntentUploadProvider.notifier).init(); } 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 0000000000..ca66e6a163 --- /dev/null +++ b/mobile/lib/models/view_intent/view_intent_payload.extension.dart @@ -0,0 +1,35 @@ +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 { + String get fileName { + final resolvedPath = path; + if (resolvedPath != null && resolvedPath.isNotEmpty) { + return basename(resolvedPath); + } + return localAssetId ?? 'view_intent_asset'; + } + + bool get isImage => mimeType.toLowerCase().startsWith('image/'); + + bool get isVideo => mimeType.toLowerCase().startsWith('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 != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) { + return AssetPlaybackStyle.imageAnimated; + } + + return AssetPlaybackStyle.image; + } +} diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index aaa9fffc05..de6fda5773 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/theme/color_scheme.dart'; @@ -314,6 +315,7 @@ class SplashScreenPageState extends ConsumerState { final wsProvider = ref.read(websocketProvider.notifier); final backgroundManager = ref.read(backgroundSyncProvider); final backupProvider = ref.read(driftBackupProvider.notifier); + final viewIntentHandler = ref.read(viewIntentHandlerProvider); unawaited( ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( @@ -328,6 +330,8 @@ class SplashScreenPageState extends ConsumerState { backgroundManager.syncRemote().then((success) => syncSuccess = success), ]); + await viewIntentHandler.flushDeferredViewIntent(); + if (syncSuccess) { await Future.wait([ backgroundManager.hashAssets().then((_) { 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 0000000000..d457c249de --- /dev/null +++ b/mobile/lib/platform/view_intent_api.g.dart @@ -0,0 +1,191 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow(List? replyList, String channelName, {required bool isNullValid}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a is double && b is double) { + if (a.isNaN && b.isNaN) { + return true; + } + return a == b; + } + if (a is List && b is List) { + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final MapEntry entryA in a.entries) { + bool found = false; + for (final MapEntry entryB in b.entries) { + if (_deepEquals(entryA.key, entryB.key)) { + if (_deepEquals(entryA.value, entryB.value)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; + } + return a == b; +} + +int _deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(_deepHash)); + } + if (value is Map) { + int result = 0; + for (final MapEntry entry in value.entries) { + result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value); + } + return result; + } + if (value is double && value.isNaN) { + // Normalize NaN to a consistent hash. + return 0x7FF8000000000000.hashCode; + } + if (value is double && value == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return 0.0.hashCode; + } + return value.hashCode; +} + +class ViewIntentPayload { + ViewIntentPayload({this.path, required this.mimeType, this.localAssetId}); + + String? path; + + String mimeType; + + String? localAssetId; + + List _toList() { + return [path, mimeType, localAssetId]; + } + + Object encode() { + return _toList(); + } + + static ViewIntentPayload decode(Object result) { + result as List; + return ViewIntentPayload( + path: result[0] as String?, + mimeType: result[1]! as String, + localAssetId: result[2] 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(path, other.path) && + _deepEquals(mimeType, other.mimeType) && + _deepEquals(localAssetId, other.localAssetId); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is ViewIntentPayload) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + 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 pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + return pigeonVar_replyValue as ViewIntentPayload?; + } +} 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 98eb09a4aa..599e11d467 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'; @@ -10,6 +11,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu 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/providers/view_intent/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_ui/immich_ui.dart'; @@ -26,7 +30,11 @@ class UploadActionButton extends ConsumerWidget { } final isTimeline = source == ActionSource.timeline; + final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null; List? assets; + var isUploadDialogOpen = false; + var wasUploadCancelled = false; + Future? uploadDialogFuture; if (source == ActionSource.timeline) { assets = ref.read(multiSelectProvider).selectedAssets.whereType().toList(); @@ -35,22 +43,50 @@ class UploadActionButton extends ConsumerWidget { } ref.read(multiSelectProvider.notifier).reset(); } else { - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => const _UploadProgressDialog(), - ), - ); + isUploadDialogOpen = true; + uploadDialogFuture = + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => _UploadProgressDialog( + onCancel: () { + wasUploadCancelled = true; + }, + ), + ).whenComplete(() { + isUploadDialogOpen = false; + }); + unawaited(uploadDialogFuture); } - final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); + var success = false; + if (!isTimeline && viewerIntentFilePath != null) { + final viewIntentService = ref.read(viewIntentServiceProvider); + viewIntentService.markUploadActive(viewerIntentFilePath); + var hasError = false; + try { + await ref + .read(foregroundUploadServiceProvider) + .uploadShareIntent( + [File(viewerIntentFilePath)], + onError: (_, _) { + hasError = true; + }, + ); + } finally { + await viewIntentService.markUploadInactive(viewerIntentFilePath); + } + success = !hasError; + } else { + final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); + success = result.success; + } - if (!isTimeline && context.mounted) { + if (!isTimeline && context.mounted && isUploadDialogOpen) { Navigator.of(context, rootNavigator: true).pop(); } - if (context.mounted && !result.success) { + if (context.mounted && !success && !wasUploadCancelled) { ImmichToast.show( context: context, msg: 'scaffold_body_error_occurred'.t(context: context), @@ -73,7 +109,9 @@ class UploadActionButton extends ConsumerWidget { } class _UploadProgressDialog extends ConsumerWidget { - const _UploadProgressDialog(); + final VoidCallback onCancel; + + const _UploadProgressDialog({required this.onCancel}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -103,7 +141,8 @@ class _UploadProgressDialog extends ConsumerWidget { onPressed: () { ref.read(manualUploadCancelTokenProvider)?.complete(); ref.read(manualUploadCancelTokenProvider.notifier).state = null; - Navigator.of(context).pop(); + onCancel(); + Navigator.of(context, rootNavigator: true).pop(); }, labelText: 'cancel'.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 84edc4df65..fdedd15a0f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -21,6 +21,7 @@ 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/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -323,14 +324,16 @@ class _AssetPageState extends ConsumerState { required PhotoViewHeroAttributes? heroAttributes, required bool isCurrent, required bool isPlayingMotionVideo, + required String? localFilePath, }) { final size = context.sizeData; + final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath); 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, @@ -377,12 +380,9 @@ class _AssetPageState extends ConsumerState { child: NativeVideoViewer( key: _NativeVideoViewerKey(asset.heroTag), asset: asset, + localFilePath: localFilePath, isCurrent: isCurrent, - image: Image( - image: getFullImageProvider(asset, size: size), - fit: BoxFit.contain, - alignment: Alignment.center, - ), + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), ), ); } @@ -393,6 +393,7 @@ class _AssetPageState extends ConsumerState { _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); + final timelineOrigin = ref.read(timelineServiceProvider).origin; final asset = _asset; if (asset == null) { @@ -421,6 +422,8 @@ class _AssetPageState extends ConsumerState { _scrollController.snapPosition.snapOffset = _snapOffset; } + final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null; + return Stack( children: [ SingleChildScrollView( @@ -440,6 +443,7 @@ class _AssetPageState extends ConsumerState { : null, isCurrent: isCurrent, isPlayingMotionVideo: isPlayingMotionVideo, + localFilePath: viewIntentFilePath, ), ), IgnorePointer( 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 c1e6fe10e6..2be7bb91e8 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'; @@ -19,6 +20,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; @@ -26,6 +28,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget { const NativeVideoViewer({ super.key, required this.asset, + this.localFilePath, required this.image, this.isCurrent = false, this.showControls = true, @@ -106,6 +109,19 @@ class _NativeVideoViewerState extends ConsumerState with Widg } 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/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 36d9678277..b3c58314db 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:ui' as ui; import 'package:async/async.dart'; @@ -146,10 +147,17 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } } -ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) { +ImageProvider getFullImageProvider( + BaseAsset asset, { + Size size = const Size(1080, 1920), + bool edited = true, + String? localFilePath, +}) { // Create new provider and cache it final ImageProvider provider; - if (_shouldUseLocalAsset(asset)) { + if (localFilePath != null) { + provider = FileImage(File(localFilePath)); + } else if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); } else { diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index 66a8deb466..8bd0581061 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -1,101 +1,101 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/share_intent_service.dart'; -import 'package:immich_mobile/services/foreground_upload.service.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; - -final shareIntentUploadProvider = StateNotifierProvider>( - ((ref) => ShareIntentUploadStateNotifier( - ref.watch(appRouterProvider), - ref.read(foregroundUploadServiceProvider), - ref.read(shareIntentServiceProvider), - )), -); - -class ShareIntentUploadStateNotifier extends StateNotifier> { - final AppRouter router; - final ForegroundUploadService _foregroundUploadService; - final ShareIntentService _shareIntentService; - final Logger _logger = Logger('ShareIntentUploadStateNotifier'); - - ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]); - - void init() { - _shareIntentService.onSharedMedia = onSharedMedia; - _shareIntentService.init(); - } - - void onSharedMedia(List attachments) { - router.removeWhere((route) => route.name == "ShareIntentRoute"); - clearAttachments(); - addAttachments(attachments); - router.push(ShareIntentRoute(attachments: attachments)); - } - - void addAttachments(List attachments) { - if (attachments.isEmpty) { - return; - } - state = [...state, ...attachments]; - } - - void removeAttachment(ShareIntentAttachment attachment) { - final updatedState = state.where((element) => element != attachment).toList(); - if (updatedState.length != state.length) { - state = updatedState; - } - } - - void clearAttachments() { - if (state.isEmpty) { - return; - } - - state = []; - } - - Future uploadAll(List files) async { - for (final file in files) { - final fileId = p.hash(file.path).toString(); - _updateStatus(fileId, UploadStatus.running); - } - - await _foregroundUploadService.uploadShareIntent( - files, - onProgress: (fileId, bytes, totalBytes) { - final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; - _updateProgress(fileId, progress); - }, - onSuccess: (fileId) { - _updateStatus(fileId, UploadStatus.complete, progress: 1.0); - }, - onError: (fileId, errorMessage) { - _logger.warning("Upload failed for file: $fileId, error: $errorMessage"); - _updateStatus(fileId, UploadStatus.failed); - }, - ); - } - - void _updateStatus(String fileId, UploadStatus status, {double? progress}) { - final id = int.parse(fileId); - state = [ - for (final attachment in state) - if (attachment.id == id) - attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress) - else - attachment, - ]; - } - - void _updateProgress(String fileId, double progress) { - final id = int.parse(fileId); - state = [ - for (final attachment in state) - if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment, - ]; - } -} +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/share_intent_service.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; + +final shareIntentUploadProvider = StateNotifierProvider>( + ((ref) => ShareIntentUploadStateNotifier( + ref.watch(appRouterProvider), + ref.read(foregroundUploadServiceProvider), + ref.read(shareIntentServiceProvider), + )), +); + +class ShareIntentUploadStateNotifier extends StateNotifier> { + final AppRouter router; + final ForegroundUploadService _foregroundUploadService; + final ShareIntentService _shareIntentService; + final Logger _logger = Logger('ShareIntentUploadStateNotifier'); + + ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]); + + void init() { + _shareIntentService.onSharedMedia = onSharedMedia; + _shareIntentService.init(); + } + + void onSharedMedia(List attachments) { + router.removeWhere((route) => route.name == "ShareIntentRoute"); + clearAttachments(); + addAttachments(attachments); + router.push(ShareIntentRoute(attachments: attachments)); + } + + void addAttachments(List attachments) { + if (attachments.isEmpty) { + return; + } + state = [...state, ...attachments]; + } + + void removeAttachment(ShareIntentAttachment attachment) { + final updatedState = state.where((element) => element != attachment).toList(); + if (updatedState.length != state.length) { + state = updatedState; + } + } + + void clearAttachments() { + if (state.isEmpty) { + return; + } + + state = []; + } + + Future uploadAll(List files) async { + for (final file in files) { + final fileId = p.hash(file.path).toString(); + _updateStatus(fileId, UploadStatus.running); + } + + await _foregroundUploadService.uploadShareIntent( + files, + onProgress: (fileId, bytes, totalBytes) { + final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; + _updateProgress(fileId, progress); + }, + onSuccess: (fileId, _) { + _updateStatus(fileId, UploadStatus.complete, progress: 1.0); + }, + onError: (fileId, errorMessage) { + _logger.warning("Upload failed for file: $fileId, error: $errorMessage"); + _updateStatus(fileId, UploadStatus.failed); + }, + ); + } + + void _updateStatus(String fileId, UploadStatus status, {double? progress}) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) + attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress) + else + attachment, + ]; + } + + void _updateProgress(String fileId, double progress) { + final id = int.parse(fileId); + state = [ + for (final attachment in state) + if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment, + ]; + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index aa734f56b8..6fdd9fc5c9 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -36,11 +36,12 @@ class ActionResult { final int count; final bool success; final String? error; + final List remoteAssetIds; - const ActionResult({required this.count, required this.success, this.error}); + const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []}); @override - String toString() => 'ActionResult(count: $count, success: $success, error: $error)'; + String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)'; } class ActionNotifier extends Notifier { @@ -554,10 +555,14 @@ class ActionNotifier extends Notifier { final uploadedAssetIds = {}; final failedAssetIds = {}; final postUploadTasks = >[]; + if (assetsToUpload.isEmpty) { + return const ActionResult(count: 0, success: false, error: 'No assets to upload'); + } final progressNotifier = ref.read(assetUploadProgressProvider.notifier); final cancelToken = Completer(); ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; + final remoteAssetIds = []; // Initialize progress for all assets for (final asset in assetsToUpload) { @@ -574,6 +579,7 @@ class ActionNotifier extends Notifier { progressNotifier.setProgress(localAssetId, progress); }, onSuccess: (localAssetId, remoteAssetId) { + remoteAssetIds.add(remoteAssetId); progressNotifier.remove(localAssetId); uploadedAssetIds.add(localAssetId); final asset = assetById[localAssetId]; diff --git a/mobile/lib/providers/view_intent/view_intent_file_path.provider.dart b/mobile/lib/providers/view_intent/view_intent_file_path.provider.dart new file mode 100644 index 0000000000..75cd304a3a --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_file_path.provider.dart @@ -0,0 +1,31 @@ +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; + } + + void clearIfMatch(String path) { + if (state != path) { + return; + } + state = null; + } +} + +final viewIntentFilePathProvider = NotifierProvider( + ViewIntentFilePathNotifier.new, +); diff --git a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart new file mode 100644 index 0000000000..b266887cab --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -0,0 +1,23 @@ +import 'dart:io'; +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_handler_android.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart'; + +abstract class ViewIntentHandler { + void init(); + + Future onAppResumed(); + + Future flushDeferredViewIntent(); + + Future handle(ViewIntentPayload attachment); +} + +final viewIntentHandlerProvider = Provider((ref) { + if (Platform.isAndroid) { + return AndroidViewIntentHandler(ref); + } + + return const StubViewIntentHandler(); +}); diff --git a/mobile/lib/providers/view_intent/view_intent_handler_android.dart b/mobile/lib/providers/view_intent/view_intent_handler_android.dart new file mode 100644 index 0000000000..c00ff38648 --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -0,0 +1,103 @@ +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/platform/view_intent_api.g.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/view_intent/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_pending.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_asset_resolver.service.dart'; +import 'package:logging/logging.dart'; + +class AndroidViewIntentHandler implements ViewIntentHandler { + final Ref _ref; + final ViewIntentService _viewIntentService; + final ViewIntentAssetResolver _viewIntentAssetResolver; + final AppRouter _router; + static final Logger _logger = Logger('ViewIntentHandler'); + + AndroidViewIntentHandler(Ref ref) + : _ref = ref, + _viewIntentService = ref.read(viewIntentServiceProvider), + _viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider), + _router = ref.watch(appRouterProvider); + + @override + void init() { + // Covers cold start from a view intent before the first lifecycle "resumed". + unawaited(onAppResumed()); + } + + @override + Future onAppResumed() => _checkForViewIntent(); + + @override + Future flushDeferredViewIntent() => _flushPending(); + + Future _checkForViewIntent() async { + final attachment = await _viewIntentService.consumeViewIntent(); + if (attachment != null) { + await handle(attachment); + return; + } + + if (_ref.read(viewIntentPendingProvider) == null) { + await _viewIntentService.cleanupStaleTempFiles(); + } + } + + Future _flushPending() async { + final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh(); + _logger.info('flushPending, pendingAttachment:$pendingAttachment'); + if (pendingAttachment != null) { + await handle(pendingAttachment); + } + } + + @override + Future handle(ViewIntentPayload attachment) async { + _logger.info( + 'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}', + ); + + if (!_ref.read(authProvider).isAuthenticated) { + _ref.read(viewIntentPendingProvider.notifier).defer(attachment); + return; + } + + final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); + _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); + await _openAssetViewer( + resolvedAsset.asset, + resolvedAsset.timelineService, + viewIntentFilePath: resolvedAsset.viewIntentFilePath, + ); + } + + Future _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async { + final notifier = _ref.read(assetViewerProvider.notifier); + notifier.reset(); + if (asset.isVideo) { + notifier.setControls(false); + } + 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()); + } + + await _router.replaceAll([ + const TabShellRoute(), + AssetViewerRoute(initialIndex: 0, timelineService: timelineService), + ]); + } +} diff --git a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart new file mode 100644 index 0000000000..ebc6d7425b --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart @@ -0,0 +1,18 @@ +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; + +class StubViewIntentHandler implements ViewIntentHandler { + const StubViewIntentHandler(); + + @override + void init() {} + + @override + Future onAppResumed() async {} + + @override + Future flushDeferredViewIntent() async {} + + @override + Future handle(ViewIntentPayload attachment) async {} +} 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 0000000000..c3f68eff79 --- /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/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index ef7f32d168..aea187dc9f 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -151,7 +151,7 @@ class ForegroundUploadService { List files, { Completer? cancelToken, void Function(String fileId, int bytes, int totalBytes)? onProgress, - void Function(String fileId)? onSuccess, + void Function(String fileId, String remoteAssetId)? onSuccess, void Function(String fileId, String errorMessage)? onError, }) async { if (files.isEmpty) { @@ -171,7 +171,7 @@ class ForegroundUploadService { ); if (result.isSuccess) { - onSuccess?.call(fileId); + onSuccess?.call(fileId, result.remoteAssetId!); } else if (!result.isCancelled && result.errorMessage != null) { onError?.call(fileId, result.errorMessage!); } diff --git a/mobile/lib/services/view_intent.service.dart b/mobile/lib/services/view_intent.service.dart new file mode 100644 index 0000000000..22a3407e5a --- /dev/null +++ b/mobile/lib/services/view_intent.service.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi())); + +class ViewIntentService { + final ViewIntentHostApi _viewIntentHostApi; + final Future Function() _temporaryDirectory; + String? _managedTempFilePath; + final Set _activeUploadPaths = {}; + + ViewIntentService(this._viewIntentHostApi, {Future Function()? temporaryDirectory}) + : _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; + + Future consumeViewIntent() async { + try { + return await _viewIntentHostApi.consumeViewIntent(); + } catch (_) { + // Ignore errors - view intent might not be present + return null; + } + } + + 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 cleanupManagedTempFileIfCurrent(String path) async { + if (_managedTempFilePath == path) { + _managedTempFilePath = null; + } + await cleanupTempFile(path); + } + + Future cleanupTempFile(String path) async { + if (!_isManagedTempFile(path)) { + return; + } + if (_activeUploadPaths.contains(path)) { + return; + } + + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) { + // Best-effort cleanup only. + } + } + + Future cleanupStaleTempFiles() async { + try { + final tempDirectory = await _temporaryDirectory(); + await for (final entity in tempDirectory.list()) { + if (entity is! File) { + continue; + } + + final path = entity.path; + if (!_isManagedTempFile(path) || path == _managedTempFilePath || _activeUploadPaths.contains(path)) { + continue; + } + + await entity.delete(); + } + } catch (_) { + // Best-effort cleanup only. + } + } + + void markUploadActive(String path) { + _activeUploadPaths.add(path); + } + + Future markUploadInactive(String path) async { + if (!_activeUploadPaths.remove(path)) { + return; + } + if (_managedTempFilePath != path) { + await cleanupTempFile(path); + } + } + + bool _isManagedTempFile(String path) { + return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache'; + } +} 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 0000000000..7bda1bdc13 --- /dev/null +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -0,0 +1,65 @@ +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/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:logging/logging.dart'; + +class ViewIntentResolvedAsset { + final BaseAsset asset; + final TimelineService timelineService; + + final String? viewIntentFilePath; + + const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath}); +} + +final viewIntentAssetResolverProvider = Provider( + (ref) => ViewIntentAssetResolver( + localAssetRepository: ref.read(localAssetRepository), + timelineFactory: ref.read(timelineFactoryProvider), + ), +); + +class ViewIntentAssetResolver { + final DriftLocalAssetRepository _localAssetRepository; + final TimelineFactory _timelineFactory; + static final Logger _logger = Logger('ViewIntentAssetResolver'); + + const ViewIntentAssetResolver({required this._localAssetRepository, required this._timelineFactory}); + + Future resolve(ViewIntentPayload attachment) async { + final localAssetId = attachment.localAssetId; + final path = attachment.path; + _logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}'); + + if (localAssetId == null && path == null) { + throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.'); + } + + final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null; + final asset = localAsset ?? _toTransientAsset(attachment); + + return ViewIntentResolvedAsset( + asset: asset, + timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink), + viewIntentFilePath: localAsset == null ? path : null, + ); + } + + LocalAsset _toTransientAsset(ViewIntentPayload attachment) { + final now = DateTime.now(); + return LocalAsset( + id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}', + name: attachment.fileName, + type: attachment.isVideo ? AssetType.video : AssetType.image, + createdAt: now, + updatedAt: now, + isEdited: false, + playbackStyle: attachment.playbackStyle, + ); + } +} diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 090c9bb2b8..7615218159 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -182,9 +183,11 @@ class LoginForm extends HookConsumerWidget { Future handleSyncFlow() async { final backgroundManager = ref.read(backgroundSyncProvider); + final viewIntentHandler = ref.read(viewIntentHandlerProvider); await backgroundManager.syncLocal(full: true); await backgroundManager.syncRemote(); + await viewIntentHandler.flushDeferredViewIntent(); await backgroundManager.hashAssets(); if (SettingsRepository.instance.appConfig.backup.syncAlbums) { @@ -259,7 +262,7 @@ class LoginForm extends HookConsumerWidget { } unawaited(handleSyncFlow()); ref.read(websocketProvider.notifier).connect(); - unawaited(context.replaceRoute(const TabShellRoute())); + unawaited(context.router.replaceAll([const TabShellRoute()])); return; } } catch (error) { @@ -346,7 +349,7 @@ class LoginForm extends HookConsumerWidget { await getManageMediaPermission(); } unawaited(handleSyncFlow()); - unawaited(context.replaceRoute(const TabShellRoute())); + unawaited(context.router.replaceAll([const TabShellRoute()])); return; } } catch (error, stack) { diff --git a/mobile/mise.toml b/mobile/mise.toml index a0f25718ac..07210d9141 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -29,7 +29,8 @@ run = [ "dart run pigeon --input pigeon/background_worker_lock_api.dart", "dart run pigeon --input pigeon/connectivity_api.dart", "dart run pigeon --input pigeon/network_api.dart", - "dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart", + "dart run pigeon --input pigeon/view_intent_api.dart", + "dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart", ] [tasks."codegen:translation"] diff --git a/mobile/pigeon/local_image_api.dart b/mobile/pigeon/local_image_api.dart index eb538d7b1a..46643b7956 100644 --- a/mobile/pigeon/local_image_api.dart +++ b/mobile/pigeon/local_image_api.dart @@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart'; dartOut: 'lib/platform/local_image_api.g.dart', swiftOut: 'ios/Runner/Images/LocalImages.g.swift', swiftOptions: SwiftOptions(includeErrorClass: false), - kotlinOut: - 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'), dartOptions: DartOptions(), dartPackageName: 'immich_mobile', diff --git a/mobile/pigeon/view_intent_api.dart b/mobile/pigeon/view_intent_api.dart new file mode 100644 index 0000000000..f6a5162fef --- /dev/null +++ b/mobile/pigeon/view_intent_api.dart @@ -0,0 +1,24 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/view_intent_api.g.dart', + 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', + ), +) +class ViewIntentPayload { + final String? path; + final String mimeType; + final String? localAssetId; + + const ViewIntentPayload({this.path, required this.mimeType, this.localAssetId}); +} + +@HostApi() +abstract class ViewIntentHostApi { + @async + ViewIntentPayload? consumeViewIntent(); +} diff --git a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart new file mode 100644 index 0000000000..f9c2c9d323 --- /dev/null +++ b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart @@ -0,0 +1,261 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_pending.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_asset_resolver.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:immich_mobile/services/widget.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {} + +class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {} + +class MockAppRouter extends Mock implements AppRouter {} + +class MockAuthService extends Mock implements AuthService {} + +class MockApiService extends Mock implements ApiService {} + +class MockUserService extends Mock implements UserService {} + +class MockSecureStorageService extends Mock implements SecureStorageService {} + +class MockWidgetService extends Mock implements WidgetService {} + +class FakePageRouteInfo extends Fake implements PageRouteInfo {} + +class FakeTimelineService extends Fake implements TimelineService {} + +class TestViewIntentService extends ViewIntentService { + ViewIntentPayload? consumedAttachment; + int cleanupStaleTempFilesCalls = 0; + int cleanupManagedTempFileCalls = 0; + final List managedTempPaths = []; + + TestViewIntentService() : super(MockViewIntentHostApi()); + + @override + Future consumeViewIntent() async => consumedAttachment; + + @override + Future cleanupStaleTempFiles() async { + cleanupStaleTempFilesCalls++; + } + + @override + Future cleanupManagedTempFile() async { + cleanupManagedTempFileCalls++; + } + + @override + Future setManagedTempFilePath(String path) async { + managedTempPaths.add(path); + } +} + +class TestAuthNotifier extends AuthNotifier { + TestAuthNotifier(Ref ref, AuthState initial) + : super( + MockAuthService(), + MockApiService(), + MockUserService(), + MockSecureStorageService(), + MockWidgetService(), + ref, + ) { + state = initial; + } + + void setAuthenticated(bool isAuthenticated) { + state = state.copyWith(isAuthenticated: isAuthenticated); + } +} + +final _handlerProvider = Provider((ref) => AndroidViewIntentHandler(ref)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late TestViewIntentService viewIntentService; + late MockViewIntentAssetResolver resolver; + late MockAppRouter router; + late TestAuthNotifier authNotifier; + late ProviderContainer container; + late AndroidViewIntentHandler handler; + late ViewIntentPayload payload; + late LocalAsset deepLinkAsset; + late TimelineService deepLinkTimelineService; + + setUpAll(() { + registerFallbackValue(FakePageRouteInfo()); + registerFallbackValue(>[]); + registerFallbackValue(FakeTimelineService()); + registerFallbackValue( + ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'), + ); + }); + + setUp(() async { + viewIntentService = TestViewIntentService(); + resolver = MockViewIntentAssetResolver(); + router = MockAppRouter(); + payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1'); + deepLinkAsset = _localAsset(id: 'local-1'); + deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink); + + when(() => router.replaceAll(any())).thenAnswer((_) async {}); + + container = ProviderContainer( + overrides: [ + viewIntentServiceProvider.overrideWithValue(viewIntentService), + viewIntentAssetResolverProvider.overrideWithValue(resolver), + appRouterProvider.overrideWithValue(router), + authProvider.overrideWith((ref) { + authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); + return authNotifier; + }), + ], + ); + + authNotifier = container.read(authProvider.notifier) as TestAuthNotifier; + handler = container.read(_handlerProvider); + + addTearDown(() async { + await deepLinkTimelineService.dispose(); + container.dispose(); + }); + }); + + test('handle defers unauthenticated attachment', () async { + authNotifier.setAuthenticated(false); + + await handler.handle(payload); + + expect(container.read(viewIntentPendingProvider), payload); + verifyNever(() => resolver.resolve(any())); + }); + + testWidgets('flushDeferredViewIntent consumes the pending attachment and routes the viewer', (tester) async { + authNotifier.setAuthenticated(false); + container.read(viewIntentPendingProvider.notifier).defer(payload); + authNotifier.setAuthenticated(true); + + when(() => resolver.resolve(payload)).thenAnswer((_) async { + return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService); + }); + + unawaited(handler.flushDeferredViewIntent()); + await tester.pump(); + await tester.pump(); + await tester.idle(); + + expect(container.read(viewIntentPendingProvider), isNull); + verify(() => resolver.resolve(payload)).called(1); + }); + + test('flushDeferredViewIntent does nothing when there is no pending attachment', () async { + await handler.flushDeferredViewIntent(); + + verifyNever(() => resolver.resolve(any())); + }); + + test('onAppResumed cleans stale temp files when no attachment is present', () async { + viewIntentService.consumedAttachment = null; + + await handler.onAppResumed(); + + expect(viewIntentService.cleanupStaleTempFilesCalls, 1); + verifyNever(() => resolver.resolve(any())); + }); + + test('onAppResumed does not clean stale temp files while pending attachment exists', () async { + viewIntentService.consumedAttachment = null; + container.read(viewIntentPendingProvider.notifier).defer(payload); + + await handler.onAppResumed(); + + expect(viewIntentService.cleanupStaleTempFilesCalls, 0); + verifyNever(() => resolver.resolve(any())); + }); + + testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async { + viewIntentService.consumedAttachment = payload; + when(() => resolver.resolve(payload)).thenAnswer( + (_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService), + ); + + unawaited(handler.onAppResumed()); + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.idle(); + + verify(() => resolver.resolve(payload)).called(1); + // Routes the user to [TabShell, AssetViewer] so back-press lands on the + // main timeline — mirrors the home-screen widget navigation pattern. + final captured = verify(() => router.replaceAll(captureAny())).captured; + expect(captured, hasLength(1)); + final routes = captured.single as List>; + expect(routes, hasLength(2)); + expect(routes[0].routeName, TabShellRoute.name); + expect(routes[1].routeName, AssetViewerRoute.name); + }); +} + +AuthState _authState({required bool isAuthenticated}) { + return AuthState( + deviceId: 'device-1', + userId: 'user-1', + userEmail: 'user@example.com', + isAuthenticated: isAuthenticated, + name: 'User', + isAdmin: false, + profileImagePath: '', + ); +} + +LocalAsset _localAsset({required String id}) { + return LocalAsset( + id: id, + name: '$id.jpg', + checksum: 'checksum-1', + type: AssetType.image, + createdAt: DateTime(2026, 4, 20), + updatedAt: DateTime(2026, 4, 20), + playbackStyle: AssetPlaybackStyle.image, + isEdited: false, + ); +} + +TimelineService _timelineServiceFromAssets(List assets, TimelineOrigin origin) { + return TimelineService(( + assetSource: (index, count) async => assets.skip(index).take(count).toList(), + bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]), + origin: origin, + )); +} + +Future _createReadyTimelineService(List assets, TimelineOrigin origin) async { + final timelineService = _timelineServiceFromAssets(assets, origin); + // Spin a few async ticks so the internal bucket subscription has populated + // the buffer before tests start asserting against totalAssets. + for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) { + await Future.delayed(Duration.zero); + } + return timelineService; +} 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 0000000000..a3982e8029 --- /dev/null +++ b/mobile/test/providers/view_intent/view_intent_pending_provider_test.dart @@ -0,0 +1,64 @@ +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', + 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', + 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_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart new file mode 100644 index 0000000000..38d2f71f88 --- /dev/null +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.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/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../infrastructure/repository.mock.dart'; + +class MockTimelineFactory extends Mock implements TimelineFactory {} + +void main() { + late MockDriftLocalAssetRepository mockLocalAssetRepository; + late MockTimelineFactory timelineFactory; + late List createdTimelineServices; + late ProviderContainer container; + + setUp(() { + mockLocalAssetRepository = MockDriftLocalAssetRepository(); + timelineFactory = MockTimelineFactory(); + createdTimelineServices = []; + + when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) { + final assets = List.from(invocation.positionalArguments[0] as List); + final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink); + createdTimelineServices.add(timelineService); + return timelineService; + }); + + container = ProviderContainer( + overrides: [ + localAssetRepository.overrideWith((ref) => mockLocalAssetRepository), + timelineFactoryProvider.overrideWith((ref) => timelineFactory), + ], + ); + + addTearDown(() async { + for (final timelineService in createdTimelineServices) { + await timelineService.dispose(); + } + container.dispose(); + }); + }); + + test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async { + final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1'); + when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); + + final result = await _resolve(container, _payload(localAssetId: 'local-1')); + + expect(result.asset, equals(localAsset)); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed'); + }); + + test('returns transient asset with temp file path when localAssetId has no DB row', () async { + when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null); + + final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg')); + + expect(result.asset, isA()); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.viewIntentFilePath, '/tmp/incoming.jpg'); + }); + + test('returns transient asset for path-only attachment', () async { + final result = await _resolve( + container, + _payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'), + ); + + expect(result.asset, isA()); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.viewIntentFilePath, '/tmp/incoming.webp'); + + final asset = result.asset as LocalAsset; + expect(asset.localId, startsWith('-')); + expect(asset.name, 'incoming.webp'); + expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated); + }); + + test('throws when neither localAssetId nor path is provided', () async { + await expectLater( + _resolve(container, _payload(localAssetId: null, path: null)), + throwsA(isA()), + ); + }); +} + +Future _resolve(ProviderContainer container, ViewIntentPayload payload) { + return container.read(viewIntentAssetResolverProvider).resolve(payload); +} + +ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) { + return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId); +} + +LocalAsset _localAsset({required String id, String? checksum}) { + return LocalAsset( + id: id, + name: '$id.jpg', + checksum: checksum, + type: AssetType.image, + createdAt: DateTime(2026, 4, 20), + updatedAt: DateTime(2026, 4, 20), + playbackStyle: AssetPlaybackStyle.image, + isEdited: false, + ); +} + +TimelineService _timelineServiceFromAssets(List assets, TimelineOrigin origin) { + return TimelineService(( + assetSource: (index, count) async => assets.skip(index).take(count).toList(), + bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]), + origin: origin, + )); +} 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 0000000000..7b3d0b85e7 --- /dev/null +++ b/mobile/test/services/view_intent_service_test.dart @@ -0,0 +1,119 @@ +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:mocktail/mocktail.dart'; + +class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {} + +void main() { + late MockViewIntentHostApi hostApi; + late ViewIntentService service; + late Directory tempRoot; + late Directory cacheDir; + + final attachment = ViewIntentPayload( + path: '/tmp/file.jpg', + mimeType: 'image/jpeg', + localAssetId: '42', + ); + + setUp(() { + hostApi = MockViewIntentHostApi(); + tempRoot = Directory.systemTemp.createTempSync('view-intent-root'); + cacheDir = Directory('${tempRoot.path}/cache')..createSync(); + service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir); + }); + + tearDown(() async { + clearInteractions(hostApi); + if (await tempRoot.exists()) { + await tempRoot.delete(recursive: true); + } + }); + + test('consumeViewIntent returns null when no attachment', () async { + when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null); + + final result = await service.consumeViewIntent(); + + expect(result, isNull); + verify(() => hostApi.consumeViewIntent()).called(1); + }); + + test('consumeViewIntent returns attachment when present', () async { + when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment); + + final result = await service.consumeViewIntent(); + + expect(result, attachment); + verify(() => hostApi.consumeViewIntent()).called(1); + }); + + test('consumeViewIntent swallows host api errors', () async { + when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom')); + + final result = await service.consumeViewIntent(); + + expect(result, isNull); + verify(() => hostApi.consumeViewIntent()).called(1); + }); + + test('setManagedTempFilePath cleans previous managed temp file', () async { + 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 defers deletion while an upload is active', () async { + final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes'); + + service.markUploadActive(tempFile.path); + await service.cleanupTempFile(tempFile.path); + + expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup'); + + await service.markUploadInactive(tempFile.path); + expect(await tempFile.exists(), isFalse); + }); + + test('cleanupTempFile ignores non-managed paths', () async { + final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content'); + + await service.cleanupTempFile(nonManagedFile.path); + + expect(await nonManagedFile.exists(), isTrue); + }); + + test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async { + final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first'); + final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second'); + final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain'); + + await service.cleanupStaleTempFiles(); + + expect(await firstFile.exists(), isFalse); + expect(await secondFile.exists(), isFalse); + expect(await unrelatedFile.exists(), isTrue); + }); + + test('cleanupStaleTempFiles skips paths with active uploads', () async { + final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale'); + final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active'); + service.markUploadActive(active.path); + + await service.cleanupStaleTempFiles(); + + expect(await stale.exists(), isFalse); + expect(await active.exists(), isTrue); + }); +}