diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 436d8c492d22d..c44213a5851af 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -89,6 +89,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 2c80b8d2bda26..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 @@ -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 @@ -20,6 +21,7 @@ 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 @@ -29,6 +31,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) @@ -51,6 +58,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) } 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/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/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..8219fd7d365cf --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -0,0 +1,226 @@ +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.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 +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 pendingIntent: 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 + 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 + } + + ioScope.launch { + try { + val mimeType = context.contentResolver.getType(uri) + if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) { + callback(Result.success(null)) + return@launch + } + + 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)) + } + } + } + + 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 + } + + val fromDocumentUri = tryExtractDocumentLocalAssetId(context, uri, mimeType) + if (fromDocumentUri != null) { + return fromDocumentUri + } + + 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 { + if (!DocumentsContract.isDocumentUri(context, uri)) { + return null + } + + 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 + } + } + + 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 + } + } + + 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 normalizedMimeType = mimeType.substringBefore(';').lowercase() + val mimeTypeExtension = MimeTypeMap + .getSingleton() + .getExtensionFromMimeType(normalizedMimeType) + ?.let { ".$it" } + + val extension = when { + normalizedMimeType.startsWith("image/") -> { + when { + normalizedMimeType.contains("jpeg") || normalizedMimeType.contains("jpg") -> ".jpg" + normalizedMimeType.contains("png") -> ".png" + normalizedMimeType.contains("gif") -> ".gif" + normalizedMimeType.contains("webp") -> ".webp" + else -> mimeTypeExtension ?: ".jpg" + } + } + normalizedMimeType.startsWith("video/") -> { + when { + normalizedMimeType.contains("mp4") -> ".mp4" + normalizedMimeType.contains("webm") -> ".webm" + normalizedMimeType.contains("3gp") -> ".3gp" + else -> mimeTypeExtension ?: ".mp4" + } + } + else -> mimeTypeExtension ?: ".tmp" + } + + 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 + } + } +} 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/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 4a284b9bdae9f..3f11da3c4b991 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -23,6 +23,8 @@ 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/auth.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'; @@ -120,12 +122,17 @@ class ImmichApp extends ConsumerStatefulWidget { } 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(viewIntentHandlerProvider).checkForViewIntent()); + unawaited(ref.read(viewIntentHandlerProvider).flushPending()); break; case AppLifecycleState.inactive: dPrint(() => "[APP STATE] inactive"); @@ -211,11 +218,18 @@ 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(viewIntentHandlerProvider).flushPending()); + } + }, fireImmediately: true); } @override void dispose() { + _authSubscription?.close(); WidgetsBinding.instance.removeObserver(this); super.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/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index 6681912c2f8fa..04098bb986296 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -623,6 +623,34 @@ class NativeSyncApi { } } + 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) { + 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 cancelHashing() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; 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/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 98eb09a4aa51f..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 @@ -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/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'; +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..8feb0553e8b50 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/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'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -286,14 +288,18 @@ 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,12 +346,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), ), ); } @@ -383,6 +386,8 @@ class _AssetPageState extends ConsumerState { _scrollController.snapPosition.snapOffset = _snapOffset; } + final viewIntentFilePath = ref.watch(viewIntentFilePathProvider); + return Stack( children: [ SingleChildScrollView( @@ -402,6 +407,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/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 0000000000000..e776fb5b7966c --- /dev/null +++ b/mobile/lib/providers/view_intent/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/view_intent/view_intent_handler.provider.dart b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart new file mode 100644 index 0000000000000..bca9e37831f3d --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -0,0 +1,92 @@ +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/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'; +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), + ), +); + +class ViewIntentHandler { + final Ref _ref; + final ViewIntentService _viewIntentService; + final ViewIntentAssetResolver _viewIntentAssetResolver; + final AppRouter _router; + static final Logger _logger = Logger('ViewIntentHandler'); + + const ViewIntentHandler(this._ref, this._viewIntentService, this._viewIntentAssetResolver, this._router); + + void init() { + unawaited(checkForViewIntent()); + unawaited(flushPending()); + } + + 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); + } + } + + Future handle(ViewIntentPayload attachment) async { + _logger.info('handle attachment: $attachment'); + 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}'); + _openAssetViewer( + resolvedAsset.asset, + resolvedAsset.timelineService, + resolvedAsset.initialIndex, + viewIntentFilePath: resolvedAsset.viewIntentFilePath, + ); + } + + 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); + } + + _router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService)); + } +} 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 new file mode 100644 index 0000000000000..fcaeac41b5ac9 --- /dev/null +++ b/mobile/lib/services/view_intent.service.dart @@ -0,0 +1,61 @@ +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; + +final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi())); + +class ViewIntentService { + final ViewIntentHostApi _viewIntentHostApi; + String? _managedTempFilePath; + + ViewIntentService(this._viewIntentHostApi); + + 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 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'; + } +} 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..a345ee58985fd --- /dev/null +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -0,0 +1,217 @@ +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_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: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(timelineFactoryProvider), + ), +); + +class ViewIntentAssetResolver { + final Ref _ref; + final DriftLocalAssetRepository _localAssetRepository; + final NativeSyncApi _nativeSyncApi; + final TimelineFactory _timelineFactory; + static final Logger _logger = Logger('ViewIntentAssetResolver'); + + const ViewIntentAssetResolver(this._ref, this._localAssetRepository, this._nativeSyncApi, 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 hashResults = await _nativeSyncApi.hashFiles([path]); + 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/makefile b/mobile/makefile index 3a0a263687368..23e767aff390d 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -13,6 +13,7 @@ pigeon: 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 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 @@ -20,6 +21,7 @@ pigeon: dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart dart format lib/platform/network_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/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) diff --git a/mobile/pigeon/view_intent_api.dart b/mobile/pigeon/view_intent_api.dart new file mode 100644 index 0000000000000..2a0ce77d3c157 --- /dev/null +++ b/mobile/pigeon/view_intent_api.dart @@ -0,0 +1,32 @@ +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', + ), +) +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/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 new file mode 100644 index 0000000000000..630478eb3a0be --- /dev/null +++ b/mobile/test/services/view_intent_service_test.dart @@ -0,0 +1,93 @@ +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; + + final attachment = ViewIntentPayload( + path: '/tmp/file.jpg', + type: ViewIntentType.image, + mimeType: 'image/jpeg', + localAssetId: '42', + ); + + setUp(() { + hostApi = MockViewIntentHostApi(); + service = ViewIntentService(hostApi); + }); + + tearDown(() async { + clearInteractions(hostApi); + }); + + 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 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); + }); +}