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