diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c44213a585..1b8d2a97fb 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -94,7 +94,6 @@ - @@ -102,7 +101,6 @@ - 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 deleted file mode 100644 index 36f207bd44..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/media/MediaStoreUtils.kt +++ /dev/null @@ -1,148 +0,0 @@ -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 -import android.util.Log -import android.webkit.MimeTypeMap - -private const val TAG = "MediaStoreUtils" - -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 resolveMimeType(context: Context, uri: Uri, fallbackMimeType: String? = null): String? { - return context.contentResolver.getType(uri) - ?: fallbackMimeType - ?: resolveMimeTypeFromDisplayName(context, uri) - ?: resolveMimeTypeFromPath(uri.path) - ?: resolveMimeTypeFromPath(uri.toString()) - } - - 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 resolveMimeTypeFromDisplayName(context: Context, uri: Uri): String? { - return try { - context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> - if (!cursor.moveToFirst()) { - return null - } - - val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (displayNameIndex < 0) { - return null - } - - resolveMimeTypeFromPath(cursor.getString(displayNameIndex)) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to resolve MIME type from display name: $uri", e) - null - } - } - - private fun resolveMimeTypeFromPath(path: String?): String? { - if (path.isNullOrBlank()) { - return null - } - - val extension = path.substringAfterLast('.', missingDelimiterValue = "").substringBefore('?').substringBefore('#') - if (extension.isBlank()) { - return null - } - - return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) - } - - 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 1daa1fbaa7..345302026d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt @@ -551,7 +551,6 @@ 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 restoreFromTrashById(mediaId: String, type: Long, callback: (Result) -> Unit) @@ -718,26 +717,6 @@ 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 bc2138546e..1f5ff2529e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -30,8 +30,6 @@ 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 @@ -422,44 +420,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa } } - 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( @@ -467,10 +427,17 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa assetId.toLong() ) - val hashString = ctx.contentResolver.openInputStream(assetUri)?.use { inputStream -> - hashInputStream(inputStream) + 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) + } } ?: 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) @@ -479,35 +446,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa } } - 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/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt index 9773906682..58117ed37e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -5,10 +5,12 @@ import android.content.ContentUris import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import android.provider.DocumentsContract +import android.provider.MediaStore +import android.provider.OpenableColumns import 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 @@ -83,7 +85,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL ioScope.launch { try { - val mimeType = MediaStoreUtils.resolveMimeType(context, uri, intent.type) + val mimeType = context.contentResolver.getType(uri) ?: intent.type if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) { callback(Result.success(null)) return@launch @@ -121,52 +123,22 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL } 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) + return tryExtractDocumentLocalAssetId(context, uri) + ?: tryParseContentUriId(uri) + ?: tryParseLastPathSegmentId(uri) + ?: resolveLocalIdByNameAndSize(context, uri, mimeType) } - private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri, mimeType: String): String? { - try { - if (!DocumentsContract.isDocumentUri(context, uri)) { - return null - } - + private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? { + return try { + if (!DocumentsContract.isDocumentUri(context, uri)) return null val docId = DocumentsContract.getDocumentId(uri) - if (docId.startsWith("raw:")) { - return null - } - - if (docId.isBlank()) { - return null - } - + if (docId.isBlank() || docId.startsWith("raw:")) return null val parsed = docId.substringAfter(':', docId) - if (parsed.all(Char::isDigit)) { - return parsed - } - - return MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType) + if (parsed.isNotEmpty() && parsed.all(Char::isDigit)) parsed else null } catch (e: Exception) { Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e) - return null + null } } @@ -187,33 +159,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL 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 extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" } val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir) context.contentResolver.openInputStream(uri)?.use { inputStream -> FileOutputStream(tempFile).use { outputStream -> @@ -225,4 +171,49 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL null } } + + private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? { + val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + val (displayName, size) = + try { + context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) return null + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null + val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L + if (name.isNullOrBlank() || bytes < 0) return null + name to bytes + } ?: return null + } catch (_: Exception) { + return null + } + + val tableUri = when { + mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + } else { + MediaStore.Files.getContentUri("external") + } + } + return try { + context.contentResolver + .query( + tableUri, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?", + arrayOf(displayName, size.toString()), + "${MediaStore.MediaColumns.DATE_MODIFIED} DESC", + )?.use { cursor -> + if (!cursor.moveToFirst()) return null + val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + if (idIndex < 0) return null + cursor.getLong(idIndex).toString() + } + } catch (_: Exception) { + null + } + } } diff --git a/mobile/ios/Runner/Sync/Messages.g.swift b/mobile/ios/Runner/Sync/Messages.g.swift index 0573936625..d18a153bb7 100644 --- a/mobile/ios/Runner/Sync/Messages.g.swift +++ b/mobile/ios/Runner/Sync/Messages.g.swift @@ -535,7 +535,6 @@ 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 restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result) -> Void) @@ -695,25 +694,6 @@ 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 249abce6c9..377e8197a1 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -319,13 +319,6 @@ 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() hashTask = nil diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index ff17c5c9ea..d0321ab1ef 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -139,150 +139,3 @@ 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; - -mergedAssetIndexByRemoteId: -SELECT - idx -FROM ( - SELECT - remote_id, - ROW_NUMBER() OVER (ORDER BY created_at DESC) - 1 as idx - FROM ( - SELECT - rae.id as remote_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 - NULL as remote_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 remote_id = :remote_id -LIMIT 1; diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 29e0ec0298..2d05ef6ceb 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -101,75 +101,6 @@ 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')); - } - - i0.Selectable mergedAssetIndexByRemoteId({ - required List userIds, - String? remoteId, - }) { - var $arrayStartIndex = 2; - final expandeduserIds = $expandVar($arrayStartIndex, userIds.length); - $arrayStartIndex += userIds.length; - return customSelect( - 'SELECT idx FROM (SELECT remote_id, ROW_NUMBER()OVER (ORDER BY created_at DESC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) - 1 AS idx FROM (SELECT rae.id AS remote_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 NULL AS remote_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 remote_id = ?1 LIMIT 1', - variables: [ - i0.Variable(remoteId), - 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 c9549f4105..bf707ad0bd 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -679,35 +679,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } } - 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; - } - - Future getMainTimelineIndexByRemoteId(List userIds, String remoteAssetId) async { - if (userIds.isEmpty) { - return null; - } - final result = await _db.mergedAssetDrift - .mergedAssetIndexByRemoteId(userIds: userIds, remoteId: remoteAssetId) - .getSingleOrNull(); - return result; - } } List _generateBuckets(int count) { diff --git a/mobile/lib/platform/native_sync_api.g.dart b/mobile/lib/platform/native_sync_api.g.dart index ffd7dc0694..18e196ae8b 100644 --- a/mobile/lib/platform/native_sync_api.g.dart +++ b/mobile/lib/platform/native_sync_api.g.dart @@ -9,14 +9,22 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List; import 'package:flutter/services.dart'; import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; -Object? _extractReplyValueOrThrow(List? replyList, String channelName, {required bool isNullValid}) { +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { if (replyList == null) { throw PlatformException( code: 'channel-error', message: 'Unable to establish connection on channel: "$channelName".', ); } else if (replyList.length > 1) { - throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]); + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { throw PlatformException( code: 'null-error', @@ -37,7 +45,9 @@ bool _deepEquals(Object? a, Object? b) { return a == b; } if (a is List && b is List) { - return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { if (a.length != b.length) { @@ -86,7 +96,15 @@ int _deepHash(Object? value) { return value.hashCode; } -enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping } + +enum PlatformAssetPlaybackStyle { + unknown, + image, + video, + imageAnimated, + livePhoto, + videoLooping, +} class PlatformAsset { PlatformAsset({ @@ -154,8 +172,7 @@ class PlatformAsset { } Object encode() { - return _toList(); - } + return _toList(); } static PlatformAsset decode(Object result) { result as List; @@ -186,20 +203,7 @@ class PlatformAsset { if (identical(this, other)) { return true; } - return _deepEquals(id, other.id) && - _deepEquals(name, other.name) && - _deepEquals(type, other.type) && - _deepEquals(createdAt, other.createdAt) && - _deepEquals(updatedAt, other.updatedAt) && - _deepEquals(width, other.width) && - _deepEquals(height, other.height) && - _deepEquals(durationMs, other.durationMs) && - _deepEquals(orientation, other.orientation) && - _deepEquals(isFavorite, other.isFavorite) && - _deepEquals(adjustmentTime, other.adjustmentTime) && - _deepEquals(latitude, other.latitude) && - _deepEquals(longitude, other.longitude) && - _deepEquals(playbackStyle, other.playbackStyle); + return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(createdAt, other.createdAt) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(width, other.width) && _deepEquals(height, other.height) && _deepEquals(durationMs, other.durationMs) && _deepEquals(orientation, other.orientation) && _deepEquals(isFavorite, other.isFavorite) && _deepEquals(adjustmentTime, other.adjustmentTime) && _deepEquals(latitude, other.latitude) && _deepEquals(longitude, other.longitude) && _deepEquals(playbackStyle, other.playbackStyle); } @override @@ -227,12 +231,17 @@ class PlatformAlbum { int assetCount; List _toList() { - return [id, name, updatedAt, isCloud, assetCount]; + return [ + id, + name, + updatedAt, + isCloud, + assetCount, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static PlatformAlbum decode(Object result) { result as List; @@ -254,11 +263,7 @@ class PlatformAlbum { if (identical(this, other)) { return true; } - return _deepEquals(id, other.id) && - _deepEquals(name, other.name) && - _deepEquals(updatedAt, other.updatedAt) && - _deepEquals(isCloud, other.isCloud) && - _deepEquals(assetCount, other.assetCount); + return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(isCloud, other.isCloud) && _deepEquals(assetCount, other.assetCount); } @override @@ -267,7 +272,12 @@ class PlatformAlbum { } class SyncDelta { - SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums}); + SyncDelta({ + required this.hasChanges, + required this.updates, + required this.deletes, + required this.assetAlbums, + }); bool hasChanges; @@ -278,12 +288,16 @@ class SyncDelta { Map> assetAlbums; List _toList() { - return [hasChanges, updates, deletes, assetAlbums]; + return [ + hasChanges, + updates, + deletes, + assetAlbums, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static SyncDelta decode(Object result) { result as List; @@ -304,10 +318,7 @@ class SyncDelta { if (identical(this, other)) { return true; } - return _deepEquals(hasChanges, other.hasChanges) && - _deepEquals(updates, other.updates) && - _deepEquals(deletes, other.deletes) && - _deepEquals(assetAlbums, other.assetAlbums); + return _deepEquals(hasChanges, other.hasChanges) && _deepEquals(updates, other.updates) && _deepEquals(deletes, other.deletes) && _deepEquals(assetAlbums, other.assetAlbums); } @override @@ -316,7 +327,11 @@ class SyncDelta { } class HashResult { - HashResult({required this.assetId, this.error, this.hash}); + HashResult({ + required this.assetId, + this.error, + this.hash, + }); String assetId; @@ -325,16 +340,23 @@ class HashResult { String? hash; List _toList() { - return [assetId, error, hash]; + return [ + assetId, + error, + hash, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static HashResult decode(Object result) { result as List; - return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?); + return HashResult( + assetId: result[0]! as String, + error: result[1] as String?, + hash: result[2] as String?, + ); } @override @@ -355,7 +377,11 @@ class HashResult { } class CloudIdResult { - CloudIdResult({required this.assetId, this.error, this.cloudId}); + CloudIdResult({ + required this.assetId, + this.error, + this.cloudId, + }); String assetId; @@ -364,16 +390,23 @@ class CloudIdResult { String? cloudId; List _toList() { - return [assetId, error, cloudId]; + return [ + assetId, + error, + cloudId, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static CloudIdResult decode(Object result) { result as List; - return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?); + return CloudIdResult( + assetId: result[0]! as String, + error: result[1] as String?, + cloudId: result[2] as String?, + ); } @override @@ -385,9 +418,7 @@ class CloudIdResult { if (identical(this, other)) { return true; } - return _deepEquals(assetId, other.assetId) && - _deepEquals(error, other.error) && - _deepEquals(cloudId, other.cloudId); + return _deepEquals(assetId, other.assetId) && _deepEquals(error, other.error) && _deepEquals(cloudId, other.cloudId); } @override @@ -395,6 +426,7 @@ class CloudIdResult { int get hashCode => _deepHash([runtimeType, ..._toList()]); } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -402,22 +434,22 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is PlatformAssetPlaybackStyle) { + } else if (value is PlatformAssetPlaybackStyle) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is PlatformAsset) { + } else if (value is PlatformAsset) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is PlatformAlbum) { + } else if (value is PlatformAlbum) { buffer.putUint8(131); writeValue(buffer, value.encode()); - } else if (value is SyncDelta) { + } else if (value is SyncDelta) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is HashResult) { + } else if (value is HashResult) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is CloudIdResult) { + } else if (value is CloudIdResult) { buffer.putUint8(134); writeValue(buffer, value.encode()); } else { @@ -452,8 +484,8 @@ class NativeSyncApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -461,8 +493,7 @@ class NativeSyncApi { final String pigeonVar_messageChannelSuffix; Future shouldFullSync() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -472,16 +503,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return pigeonVar_replyValue! as bool; } Future getMediaChanges() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -491,16 +522,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return pigeonVar_replyValue! as SyncDelta; } Future checkpointSync() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -509,12 +540,16 @@ class NativeSyncApi { final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; } Future clearSyncCheckpoint() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -523,12 +558,16 @@ class NativeSyncApi { final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; } Future> getAssetIdsForAlbum(String albumId) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -538,16 +577,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return (pigeonVar_replyValue! as List).cast(); } Future> getAlbums() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -557,16 +596,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return (pigeonVar_replyValue! as List).cast(); } Future getAssetsCountSince(String albumId, int timestamp) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -576,16 +615,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return pigeonVar_replyValue! as int; } Future> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -595,16 +634,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return (pigeonVar_replyValue! as List).cast(); } Future> hashAssets(List assetIds, {bool allowNetworkAccess = false}) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -614,35 +653,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); - return (pigeonVar_replyValue! as List).cast(); - } - - Future> hashFiles(List paths) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashFiles$pigeonVar_messageChannelSuffix'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([paths]); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - - final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return (pigeonVar_replyValue! as List).cast(); } Future cancelHashing() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -651,12 +671,16 @@ class NativeSyncApi { final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; } Future>> getTrashedAssets() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -666,16 +690,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return (pigeonVar_replyValue! as Map).cast>(); } Future restoreFromTrashById(String mediaId, int type) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -685,16 +709,16 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return pigeonVar_replyValue! as bool; } Future> getCloudIdForAssetIds(List assetIds) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -704,10 +728,11 @@ class NativeSyncApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; return (pigeonVar_replyValue! as List).cast(); } } 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 db3b56f03f..f08abdd8de 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -8,11 +8,9 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/main_timeline_handoff.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.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'; @@ -61,16 +59,12 @@ class UploadActionButton extends ConsumerWidget { } var success = false; - String? uploadedRemoteAssetId; if (!isTimeline && viewerIntentFilePath != null) { var hasError = false; await ref .read(foregroundUploadServiceProvider) .uploadShareIntent( [File(viewerIntentFilePath)], - onSuccess: (_, remoteAssetId) { - uploadedRemoteAssetId = remoteAssetId; - }, onError: (fileId, errorMessage) { hasError = true; }, @@ -79,18 +73,12 @@ class UploadActionButton extends ConsumerWidget { } else { final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); success = result.success; - uploadedRemoteAssetId = result.remoteAssetIds.isNotEmpty ? result.remoteAssetIds.first : null; } if (!isTimeline && context.mounted && isUploadDialogOpen) { Navigator.of(context, rootNavigator: true).pop(); } - if (!isTimeline && success) { - final origin = ref.read(timelineServiceProvider).origin; - unawaited(ref.read(mainTimelineHandoffProvider).startIfNeeded(origin, remoteAssetId: uploadedRemoteAssetId)); - } - if (context.mounted && !success && !wasUploadCancelled) { ImmichToast.show( context: context, 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 a0ae6e90d7..8a6cf1f7bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.prov 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/asset_viewer/main_timeline_handoff.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'; @@ -79,7 +78,6 @@ class _AssetViewerState extends ConsumerState { StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; - MainTimelineHandoffCoordinator? _mainTimelineHandoffCoordinator; bool _disposeStarted = false; void _onTapNavigate(int direction) { @@ -99,9 +97,6 @@ class _AssetViewerState extends ConsumerState { void initState() { super.initState(); - if (ref.read(timelineServiceProvider).origin == TimelineOrigin.deepLink) { - _mainTimelineHandoffCoordinator = ref.read(mainTimelineHandoffProvider); - } final asset = ref.read(assetViewerProvider).currentAsset; assert(asset != null, "Current asset should not be null when opening the AssetViewer"); if (asset != null) { @@ -119,7 +114,6 @@ class _AssetViewerState extends ConsumerState { @override void dispose() { _disposeStarted = true; - _mainTimelineHandoffCoordinator?.cancel(); _pageController.dispose(); _preloader.dispose(); _reloadSubscription?.cancel(); diff --git a/mobile/lib/providers/asset_viewer/main_timeline_handoff.provider.dart b/mobile/lib/providers/asset_viewer/main_timeline_handoff.provider.dart deleted file mode 100644 index d5d6e325b5..0000000000 --- a/mobile/lib/providers/asset_viewer/main_timeline_handoff.provider.dart +++ /dev/null @@ -1,270 +0,0 @@ -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/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; -import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/view_intent.service.dart'; - -typedef MainTimelineIndexLookup = Future Function(List userIds, String value); -typedef MainTimelineHandoff = Future Function(List userIds, int index, String? viewIntentFilePath); -typedef UploadReadyWaiter = Future Function(bool Function(dynamic data) predicate, Duration timeout); -typedef TimelineReadyWaiter = Future Function(TimelineService timelineService, Duration timeout); - -final mainTimelineHandoffProvider = Provider.autoDispose((ref) { - final keepAliveLink = ref.keepAlive(); - - final coordinator = MainTimelineHandoffCoordinator( - getCurrentAsset: () => ref.read(assetViewerProvider).currentAsset, - getViewIntentFilePath: () => ref.read(viewIntentFilePathProvider), - resolveMainTimelineUsers: () { - final timelineUsers = ref.read(timelineUsersProvider).valueOrNull; - final currentUserId = ref.read(currentUserProvider)?.id; - return timelineUsers ?? (currentUserId != null ? [currentUserId] : const []); - }, - findMainTimelineIndexByRemoteId: (userIds, remoteAssetId) { - return ref.read(timelineRepositoryProvider).getMainTimelineIndexByRemoteId(userIds, remoteAssetId); - }, - waitForUploadReadyEvent: (predicate, timeout) { - return ref.read(websocketProvider.notifier).waitForEvent('AssetUploadReadyV1', predicate, timeout); - }, - handoffToMainTimeline: (userIds, index, viewIntentFilePath) async { - final timelineService = ref.read(timelineFactoryProvider).main(userIds); - - try { - final asset = await resolveAssetFromMainTimelineService( - timelineService, - index, - waitForTimelineReady: waitForTimelineReady, - ); - if (asset == null) { - await timelineService.dispose(); - return; - } - - ref.read(assetViewerProvider.notifier).setViewerTransitionInProgress(true); - ref.read(assetViewerProvider.notifier).setAsset(asset); - try { - await ref - .read(appRouterProvider) - .popAndPush(AssetViewerRoute(initialIndex: index, timelineService: timelineService)); - } finally { - ref.read(assetViewerProvider.notifier).setViewerTransitionInProgress(false); - } - - if (viewIntentFilePath != null) { - ref.read(viewIntentFilePathProvider.notifier).clearIfMatch(viewIntentFilePath); - await ref.read(viewIntentServiceProvider).cleanupManagedTempFileIfCurrent(viewIntentFilePath); - } - } catch (_) { - ref.read(assetViewerProvider.notifier).setViewerTransitionInProgress(false); - await timelineService.dispose(); - } - }, - ); - - final lifetimeTimer = Timer(const Duration(seconds: 40), keepAliveLink.close); - - ref - ..onDispose(lifetimeTimer.cancel) - ..onDispose(keepAliveLink.close) - ..onDispose(coordinator.dispose); - return coordinator; -}); - -Future resolveAssetFromMainTimelineService( - TimelineService timelineService, - int index, { - required TimelineReadyWaiter waitForTimelineReady, - Duration timeout = const Duration(seconds: 3), - Duration retryInterval = const Duration(milliseconds: 100), -}) async { - final deadline = DateTime.now().add(timeout); - - if (!timelineService.isReady) { - try { - await waitForTimelineReady(timelineService, timeout); - } catch (_) { - return null; - } - } - - while (DateTime.now().isBefore(deadline)) { - final totalAssets = timelineService.totalAssets; - - if (index < totalAssets) { - final asset = await timelineService.getAssetAsync(index); - if (asset != null) { - return asset; - } - } - - await Future.delayed(retryInterval); - } - - return null; -} - -class MainTimelineHandoffCoordinator { - final BaseAsset? Function() _getCurrentAsset; - final String? Function() _getViewIntentFilePath; - final List Function() _resolveMainTimelineUsers; - final MainTimelineIndexLookup _findMainTimelineIndexByRemoteId; - final UploadReadyWaiter _waitForUploadReadyEvent; - final MainTimelineHandoff _handoffToMainTimeline; - final Duration _uploadReadyTimeout; - final Duration _mainTimelineAvailabilityTimeout; - final Duration _mainTimelineRetryInterval; - - bool _disposed = false; - int _operationId = 0; - - MainTimelineHandoffCoordinator({ - required BaseAsset? Function() getCurrentAsset, - required String? Function() getViewIntentFilePath, - required List Function() resolveMainTimelineUsers, - required MainTimelineIndexLookup findMainTimelineIndexByRemoteId, - required UploadReadyWaiter waitForUploadReadyEvent, - required MainTimelineHandoff handoffToMainTimeline, - Duration uploadReadyTimeout = const Duration(seconds: 15), - Duration mainTimelineAvailabilityTimeout = const Duration(seconds: 15), - Duration mainTimelineRetryInterval = const Duration(milliseconds: 250), - }) : _getCurrentAsset = getCurrentAsset, - _getViewIntentFilePath = getViewIntentFilePath, - _resolveMainTimelineUsers = resolveMainTimelineUsers, - _findMainTimelineIndexByRemoteId = findMainTimelineIndexByRemoteId, - _waitForUploadReadyEvent = waitForUploadReadyEvent, - _handoffToMainTimeline = handoffToMainTimeline, - _uploadReadyTimeout = uploadReadyTimeout, - _mainTimelineAvailabilityTimeout = mainTimelineAvailabilityTimeout, - _mainTimelineRetryInterval = mainTimelineRetryInterval; - - Future startIfNeeded(TimelineOrigin origin, {String? remoteAssetId}) async { - if (_disposed || origin != TimelineOrigin.deepLink) { - return; - } - - final currentAsset = _getCurrentAsset(); - final viewIntentFilePath = _getViewIntentFilePath(); - if (currentAsset == null || remoteAssetId == null) { - return; - } - - final userIds = _resolveMainTimelineUsers(); - if (userIds.isEmpty) { - return; - } - - final operationId = ++_operationId; - - final match = _MainTimelineMatchCandidate(remoteAssetId: remoteAssetId); - if (!_isOperationActive(operationId)) { - return; - } - - final handoffContext = _MainTimelineHandoffContext( - match: match, - viewIntentFilePath: viewIntentFilePath, - operationId: operationId, - ); - - final didHandoffImmediately = await _tryHandoff(userIds, handoffContext); - if (didHandoffImmediately || !_isOperationActive(handoffContext.operationId)) { - return; - } - - try { - await _waitForUploadReadyEvent( - (data) => _matchesUploadReadyEvent(data, handoffContext.match.remoteAssetId), - _uploadReadyTimeout, - ); - } on TimeoutException { - return; - } catch (_) { - return; - } - - if (!_isOperationActive(handoffContext.operationId)) { - return; - } - - await _waitForMainTimelineAvailability(userIds, handoffContext); - } - - void cancel() { - if (_disposed) { - return; - } - _operationId++; - } - - Future dispose() async { - _disposed = true; - _operationId++; - } - - Future _waitForMainTimelineAvailability( - List userIds, - _MainTimelineHandoffContext handoffContext, - ) async { - final deadline = DateTime.now().add(_mainTimelineAvailabilityTimeout); - - while (_isOperationActive(handoffContext.operationId) && DateTime.now().isBefore(deadline)) { - final didHandoff = await _tryHandoff(userIds, handoffContext); - if (didHandoff) { - return true; - } - await Future.delayed(_mainTimelineRetryInterval); - } - - return false; - } - - Future _tryHandoff(List userIds, _MainTimelineHandoffContext handoffContext) async { - if (!_isOperationActive(handoffContext.operationId)) { - return false; - } - - final index = await _findIndex(userIds, handoffContext.match); - if (index == null || !_isOperationActive(handoffContext.operationId)) { - return false; - } - - await _handoffToMainTimeline(userIds, index, handoffContext.viewIntentFilePath); - return true; - } - - Future _findIndex(List userIds, _MainTimelineMatchCandidate match) async { - return _findMainTimelineIndexByRemoteId(userIds, match.remoteAssetId); - } - - bool _matchesUploadReadyEvent(dynamic data, String remoteAssetId) { - final eventRemoteAssetId = switch (data) { - {'asset': {'id': final String eventRemoteAssetId}} => eventRemoteAssetId, - _ => null, - }; - - return eventRemoteAssetId == remoteAssetId; - } - - bool _isOperationActive(int operationId) => !_disposed && _operationId == operationId; -} - -class _MainTimelineMatchCandidate { - final String remoteAssetId; - - const _MainTimelineMatchCandidate({required this.remoteAssetId}); -} - -class _MainTimelineHandoffContext { - final _MainTimelineMatchCandidate match; - final String? viewIntentFilePath; - final int operationId; - - const _MainTimelineHandoffContext({required this.match, required this.viewIntentFilePath, required this.operationId}); -} diff --git a/mobile/lib/providers/view_intent/view_intent_handler_android.dart b/mobile/lib/providers/view_intent/view_intent_handler_android.dart index c6e6cdd46f..63bc8c9c8e 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -1,13 +1,11 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart'; @@ -83,43 +81,19 @@ class AndroidViewIntentHandler implements ViewIntentHandler { return; } - final resolvedAsset = await _viewIntentAssetResolver.resolve( - attachment, - timelineUsers: _resolveMainTimelineUsers(), - mainTimelineService: _ref.read(timelineServiceProvider), - ); + final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); await _openAssetViewer( resolvedAsset.asset, resolvedAsset.timelineService, - resolvedAsset.initialIndex, viewIntentFilePath: resolvedAsset.viewIntentFilePath, ); } - List _resolveMainTimelineUsers() { - final timelineUsers = _ref.read(timelineUsersProvider).valueOrNull; - final currentUserId = _ref.read(authProvider).userId; - final effectiveTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty ? timelineUsers : [currentUserId]; - _logger.fine( - 'resolve main timeline users source, timelineUsers=$timelineUsers, currentUserId=$currentUserId, effective=$effectiveTimelineUsers', - ); - return effectiveTimelineUsers; - } - - Future _openAssetViewer( - BaseAsset asset, - TimelineService timelineService, - int initialIndex, { - String? viewIntentFilePath, - }) async { + Future _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async { final notifier = _ref.read(assetViewerProvider.notifier); notifier.setViewerTransitionInProgress(true); try { - _router.removeWhere((route) => route.name == AssetViewerRoute.name); - - await _waitForNextFrame(); - prepareAssetViewerState(notifier, asset); if (viewIntentFilePath != null) { _ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath); @@ -129,20 +103,16 @@ class AndroidViewIntentHandler implements ViewIntentHandler { unawaited(_viewIntentService.cleanupManagedTempFile()); } - unawaited(_router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService))); - await _waitForNextFrame(); + // Mirror the home-screen widget pattern: replace the route stack so + // the viewer sits directly on top of the main timeline. Back-press + // from the viewer lands the user on the timeline rather than on + // whatever route happened to be current (e.g. splash, login). + await _router.replaceAll([ + const TabShellRoute(), + AssetViewerRoute(initialIndex: 0, timelineService: timelineService), + ]); } finally { notifier.setViewerTransitionInProgress(false); } } - - Future _waitForNextFrame() { - final completer = Completer(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!completer.isCompleted) { - completer.complete(); - } - }); - return completer.future; - } } diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 13d6473d87..1b11e90fa9 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -1,262 +1,78 @@ -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/infrastructure/repositories/timeline.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:logging/logging.dart'; class ViewIntentResolvedAsset { final BaseAsset asset; final TimelineService timelineService; - final int initialIndex; + + /// Path to the materialized temp file backing this asset, if any. Set only + /// for the transient deep-link case (no DB-backed local asset). The upload + /// flow reads this to know which file to upload. final String? viewIntentFilePath; - const ViewIntentResolvedAsset({ - required this.asset, - required this.timelineService, - required this.initialIndex, - this.viewIntentFilePath, - }); + const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath}); } final viewIntentAssetResolverProvider = Provider( (ref) => ViewIntentAssetResolver( localAssetRepository: ref.read(localAssetRepository), - nativeSyncApi: ref.read(nativeSyncApiProvider), timelineFactory: ref.read(timelineFactoryProvider), - timelineRepository: ref.read(timelineRepositoryProvider), ), ); +/// Resolves an incoming ACTION_VIEW intent into the data the asset viewer +/// needs: a [BaseAsset] and a [TimelineService] containing it. +/// +/// Always wraps the resolved asset in a 1-element [TimelineOrigin.deepLink] +/// timeline — mirroring how the app's home-screen widgets open a single +/// asset. We don't try to map the asset to its position in the user's main +/// timeline because that would require ROW_NUMBER queries over the full +/// merged timeline (slow at scale) and complex "wait until the main timeline +/// service is ready at that index" coordination. Back-navigation from the +/// viewer lands on the main timeline because the handler pushes the viewer +/// on top of [TabShellRoute]. class ViewIntentAssetResolver { final DriftLocalAssetRepository _localAssetRepository; - final NativeSyncApi _nativeSyncApi; final TimelineFactory _timelineFactory; - final DriftTimelineRepository _timelineRepository; static final Logger _logger = Logger('ViewIntentAssetResolver'); const ViewIntentAssetResolver({ required DriftLocalAssetRepository localAssetRepository, - required NativeSyncApi nativeSyncApi, required TimelineFactory timelineFactory, - required DriftTimelineRepository timelineRepository, }) : _localAssetRepository = localAssetRepository, - _nativeSyncApi = nativeSyncApi, - _timelineFactory = timelineFactory, - _timelineRepository = timelineRepository; + _timelineFactory = timelineFactory; - Future resolve( - ViewIntentPayload attachment, { - required List timelineUsers, - required TimelineService mainTimelineService, - }) async { + Future resolve(ViewIntentPayload attachment) async { final localAssetId = attachment.localAssetId; final path = attachment.path; _logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}'); + if (localAssetId == null && path == null) { throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.'); } - if (localAssetId != null) { - // Try the direct local-id match first when the intent resolves to a real - // MediaStore asset. - final mainTimelineAsset = await _resolveMainTimelineAssetByLocalId( - localAssetId, - timelineUsers, - mainTimelineService, - ); - if (mainTimelineAsset != null) { - _logger.fine('presenting main timeline asset via localAssetId: ${mainTimelineAsset.asset}'); - return mainTimelineAsset; - } - } - + // Prefer the DB-backed local asset when we have one — it carries richer + // metadata than the transient model we'd otherwise synthesise. final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null; - _logger.fine('resolve local asset loaded: $localAsset'); - - final checksum = await _resolveChecksumForMatching(attachment, localAsset: localAsset); - _logger.fine('resolve checksum for matching: $checksum'); - if (checksum != null) { - final mainTimelineAsset = await _resolveMainTimelineAssetByChecksum(checksum, timelineUsers, mainTimelineService); - if (mainTimelineAsset != null) { - final lookupType = localAssetId != null ? 'checksum fallback' : 'checksum-only match'; - _logger.fine('presenting main timeline asset via $lookupType: ${mainTimelineAsset.asset}'); - return mainTimelineAsset; - } - } - - final fallbackAsset = _toFallbackAsset(attachment, localAsset: localAsset, checksum: checksum); - if (localAsset != null) { - _logger.fine('resolve fallback to deep-link local asset: $fallbackAsset'); - } else { - _logger.fine('resolve fallback to transient deep-link asset: $fallbackAsset'); - } + final asset = localAsset ?? _toTransientAsset(attachment); return ViewIntentResolvedAsset( - asset: fallbackAsset, - timelineService: _timelineFactory.fromAssets([fallbackAsset], TimelineOrigin.deepLink), - initialIndex: 0, + asset: asset, + timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink), + // viewIntentFilePath is only meaningful for the transient case — the + // DB-backed local asset carries its own path/URI for the upload flow. viewIntentFilePath: localAsset == null ? path : null, ); } - Future _resolveMainTimelineAssetByLocalId( - String localAssetId, - List timelineUsers, - TimelineService mainTimelineService, - ) async { - _logger.fine('resolve main timeline by localId start: $localAssetId'); - return _resolveMainTimelineAsset( - () => _timelineRepository.getMainTimelineIndexByLocalId(timelineUsers, localAssetId), - timelineUsers: timelineUsers, - mainTimelineService: mainTimelineService, - lookupLabel: 'localId=$localAssetId', - ); - } - - Future _resolveMainTimelineAssetByChecksum( - String checksum, - List timelineUsers, - TimelineService mainTimelineService, - ) async { - // 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. - _logger.fine('resolve main timeline by checksum start: $checksum'); - return _resolveMainTimelineAsset( - () => _timelineRepository.getMainTimelineIndexByChecksum(timelineUsers, checksum), - timelineUsers: timelineUsers, - mainTimelineService: mainTimelineService, - lookupLabel: 'checksum=$checksum', - ); - } - - Future _resolveMainTimelineAsset( - Future Function() findIndex, { - required List timelineUsers, - required TimelineService mainTimelineService, - required String lookupLabel, - }) async { - _logger.fine('resolve main timeline users for $lookupLabel: $timelineUsers'); - if (timelineUsers.isEmpty) { - _logger.fine('resolve main timeline aborted for $lookupLabel: timelineUsers is empty'); - return null; - } - - final index = await findIndex(); - _logger.fine('resolve main timeline index for $lookupLabel: $index'); - if (index == null) { - return null; - } - - return _resolveMainTimelineAssetAt(index, mainTimelineService); - } - - Future _resolveMainTimelineAssetAt(int index, TimelineService timelineService) async { - _logger.fine( - 'resolve main timeline asset at index start: index=$index, origin=${timelineService.origin}, totalAssets=${timelineService.totalAssets}', - ); - if (!timelineService.isReady) { - try { - await waitForTimelineReady(timelineService, const Duration(seconds: 3)); - } catch (_) { - return null; - } - } - - BaseAsset? asset; - final deadline = DateTime.now().add(const Duration(seconds: 3)); - while (DateTime.now().isBefore(deadline)) { - if (index < timelineService.totalAssets) { - asset = await timelineService.getAssetAsync(index); - if (asset != null) { - break; - } - } - await Future.delayed(const Duration(milliseconds: 100)); - } - _logger.fine( - 'resolve main timeline asset at index result: index=$index, totalAssetsAfterWait=${timelineService.totalAssets}, asset=$asset', - ); - if (asset == null) { - return null; - } - - return ViewIntentResolvedAsset(asset: asset, timelineService: timelineService, initialIndex: index); - } - - Future _resolveChecksumForMatching(ViewIntentPayload attachment, {LocalAsset? localAsset}) async { - final localChecksum = localAsset?.checksum; - if (localChecksum != null) { - _logger.fine('resolve checksum from local db: $localChecksum'); - return localChecksum; - } - - final localAssetId = attachment.localAssetId; - if (localAssetId != null) { - _logger.fine('resolve checksum by hashing local asset: $localAssetId'); - return _computeChecksumForLocalAsset(localAssetId); - } - final path = attachment.path; - if (path == null) { - _logger.fine('resolve checksum aborted: path is null'); - return null; - } - _logger.fine('resolve checksum by hashing path: $path'); - return _computeChecksumForPath(path); - } - - Future _computeChecksumForLocalAsset(String localAssetId) async { - try { - final hashResults = await _nativeSyncApi.hashAssets([localAssetId]); - if (hashResults.isEmpty) { - _logger.fine('compute checksum for local asset returned empty: $localAssetId'); - return null; - } - _logger.fine('compute checksum for local asset succeeded: $localAssetId -> ${hashResults.first.hash}'); - return hashResults.first.hash; - } catch (error, stackTrace) { - _logger.warning('compute checksum for local asset failed: $localAssetId', error, stackTrace); - return null; - } - } - - Future _computeChecksumForPath(String path) async { - try { - final hashResults = await _nativeSyncApi.hashFiles([path]); - if (hashResults.isEmpty) { - _logger.fine('compute checksum for path returned empty: $path'); - return null; - } - _logger.fine('compute checksum for path succeeded: $path -> ${hashResults.first.hash}'); - return hashResults.first.hash; - } catch (error, stackTrace) { - _logger.warning('compute checksum for path failed: $path', error, stackTrace); - return null; - } - } - - LocalAsset _toFallbackAsset(ViewIntentPayload attachment, {LocalAsset? localAsset, String? checksum}) { - if (localAsset == null) { - return _toViewIntentAsset(attachment, checksum); - } - - if (checksum == null || checksum == localAsset.checksum) { - return localAsset; - } - - return localAsset.copyWith(checksum: checksum); - } - - LocalAsset _toViewIntentAsset(ViewIntentPayload attachment, String? checksum) { + LocalAsset _toTransientAsset(ViewIntentPayload attachment) { final now = DateTime.now(); return LocalAsset( // TODO(Ombodi): Introduce a file-backed BaseAsset for path-only view intents. @@ -264,7 +80,6 @@ class ViewIntentAssetResolver { // adapts an unmanaged file into the existing timeline/viewer pipeline. id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}', name: attachment.fileName, - checksum: checksum, type: attachment.isVideo ? AssetType.video : AssetType.image, createdAt: now, updatedAt: now, diff --git a/mobile/pigeon/native_sync_api.dart b/mobile/pigeon/native_sync_api.dart index c4ee3af945..9775973694 100644 --- a/mobile/pigeon/native_sync_api.dart +++ b/mobile/pigeon/native_sync_api.dart @@ -130,10 +130,6 @@ 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/test/providers/asset_viewer/main_timeline_handoff_provider_test.dart b/mobile/test/providers/asset_viewer/main_timeline_handoff_provider_test.dart deleted file mode 100644 index 6d8c501ba3..0000000000 --- a/mobile/test/providers/asset_viewer/main_timeline_handoff_provider_test.dart +++ /dev/null @@ -1,221 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; -import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/providers/asset_viewer/main_timeline_handoff.provider.dart'; - -void main() { - late BaseAsset? currentAsset; - late String? viewIntentFilePath; - late int? remoteIdIndex; - late List<(List, int, String?)> handoffs; - late Completer uploadReadyCompleter; - late bool Function(dynamic)? uploadReadyPredicate; - late MainTimelineHandoffCoordinator coordinator; - - Future flush() => Future.delayed(Duration.zero); - - setUp(() { - currentAsset = _asset(checksum: 'checksum-1'); - viewIntentFilePath = null; - remoteIdIndex = null; - handoffs = []; - uploadReadyCompleter = Completer(); - uploadReadyPredicate = null; - - coordinator = MainTimelineHandoffCoordinator( - getCurrentAsset: () => currentAsset, - getViewIntentFilePath: () => viewIntentFilePath, - resolveMainTimelineUsers: () => ['user-1'], - findMainTimelineIndexByRemoteId: (_, __) async => remoteIdIndex, - waitForUploadReadyEvent: (predicate, _) { - uploadReadyPredicate = predicate; - return uploadReadyCompleter.future; - }, - handoffToMainTimeline: (userIds, index, viewIntentFilePath) async { - handoffs.add((userIds, index, viewIntentFilePath)); - }, - uploadReadyTimeout: const Duration(seconds: 1), - mainTimelineAvailabilityTimeout: const Duration(seconds: 1), - mainTimelineRetryInterval: const Duration(milliseconds: 10), - ); - - addTearDown(coordinator.dispose); - }); - - test('does not start outside deepLink origin', () async { - remoteIdIndex = 5; - - await coordinator.startIfNeeded(TimelineOrigin.main, remoteAssetId: 'remote-1'); - await flush(); - - expect(handoffs, isEmpty); - expect(uploadReadyPredicate, isNull); - }); - - test('hands off immediately when asset is already found in main timeline', () async { - remoteIdIndex = 5; - - await coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1'); - await flush(); - - expect(handoffs, hasLength(1)); - expect(handoffs.single.$1, ['user-1']); - expect(handoffs.single.$2, 5); - expect(handoffs.single.$3, isNull); - expect(uploadReadyPredicate, isNull); - }); - - test('waits for AssetUploadReadyV1 and then hands off when asset appears in main timeline', () async { - final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1'); - await flush(); - - expect(handoffs, isEmpty); - expect(uploadReadyPredicate, isNotNull); - expect( - uploadReadyPredicate!({ - 'asset': {'id': 'remote-1'}, - }), - isTrue, - ); - - remoteIdIndex = 7; - uploadReadyCompleter.complete(); - await start; - - expect(handoffs, hasLength(1)); - expect(handoffs.single.$1, ['user-1']); - expect(handoffs.single.$2, 7); - expect(handoffs.single.$3, isNull); - }); - - test('does not start when remote asset id is missing', () async { - currentAsset = _asset(localId: 'local-42', checksum: null); - - await coordinator.startIfNeeded(TimelineOrigin.deepLink); - await flush(); - - expect(handoffs, isEmpty); - expect(uploadReadyPredicate, isNull); - }); - - test('waits for AssetUploadReadyV1 when remote asset id is provided', () async { - currentAsset = _remoteAsset(localId: null, checksum: null); - - final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-9'); - await flush(); - - expect(handoffs, isEmpty); - expect(uploadReadyPredicate, isNotNull); - - await coordinator.dispose(); - uploadReadyCompleter.complete(); - await start; - }); - - test('captures view intent file path at handoff start', () async { - viewIntentFilePath = '/tmp/view_intent_old.jpg'; - final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1'); - await flush(); - - viewIntentFilePath = '/tmp/view_intent_new.jpg'; - remoteIdIndex = 4; - uploadReadyCompleter.complete(); - await start; - - expect(handoffs, hasLength(1)); - expect(handoffs.single.$3, '/tmp/view_intent_old.jpg'); - }); - - test('cancel prevents handoff after AssetUploadReadyV1 arrives later', () async { - final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1'); - await flush(); - - coordinator.cancel(); - remoteIdIndex = 8; - uploadReadyCompleter.complete(); - await start; - - expect(handoffs, isEmpty); - }); - - test('dispose prevents handoff after AssetUploadReadyV1 arrives later', () async { - final start = coordinator.startIfNeeded(TimelineOrigin.deepLink, remoteAssetId: 'remote-1'); - await flush(); - - await coordinator.dispose(); - remoteIdIndex = 9; - uploadReadyCompleter.complete(); - await start; - - expect(handoffs, isEmpty); - }); - - test('resolveAssetFromMainTimelineService waits for timeline readiness', () async { - final buckets = StreamController>(); - final asset = _remoteAsset(localId: 'local-1', checksum: 'checksum-1'); - final timelineService = TimelineService(( - assetSource: (index, count) async => [asset], - bucketSource: () => buckets.stream, - origin: TimelineOrigin.main, - )); - - addTearDown(() async { - await timelineService.dispose(); - await buckets.close(); - }); - - expect(timelineService.isReady, isFalse); - - final resolveFuture = resolveAssetFromMainTimelineService( - timelineService, - 0, - waitForTimelineReady: (timelineService, _) async { - for (var i = 0; i < 20 && !timelineService.isReady; i++) { - await Future.delayed(Duration.zero); - } - }, - timeout: const Duration(seconds: 1), - retryInterval: const Duration(milliseconds: 10), - ); - - await flush(); - expect(timelineService.isReady, isFalse); - - buckets.add(const [Bucket(assetCount: 1)]); - - final resolved = await resolveFuture; - await flush(); - expect(timelineService.isReady, isTrue); - expect(resolved, same(asset)); - }); -} - -LocalAsset _asset({String localId = 'local-1', String? checksum}) { - return LocalAsset( - id: localId, - name: 'asset.jpg', - checksum: checksum, - type: AssetType.image, - createdAt: DateTime(2026, 4, 20), - updatedAt: DateTime(2026, 4, 20), - playbackStyle: AssetPlaybackStyle.image, - isEdited: false, - ); -} - -RemoteAsset _remoteAsset({String? localId, String? checksum}) { - return RemoteAsset( - id: 'remote-1', - localId: localId, - ownerId: 'user-1', - name: 'asset.jpg', - checksum: checksum, - type: AssetType.image, - createdAt: DateTime(2026, 4, 20), - updatedAt: DateTime(2026, 4, 20), - isEdited: false, - ); -} diff --git a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart index 6bf94d39d7..18216683ff 100644 --- a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart +++ b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart @@ -88,8 +88,6 @@ class TestAuthNotifier extends AuthNotifier { } } -bool _pageRoutePredicate(PageRouteInfo route) => false; - final _handlerProvider = Provider((ref) => AndroidViewIntentHandler(ref)); void main() { @@ -107,10 +105,11 @@ void main() { setUpAll(() { registerFallbackValue(FakePageRouteInfo()); - registerFallbackValue(_pageRoutePredicate); - registerFallbackValue(_localAsset(id: 'fallback')); - registerFallbackValue([]); + registerFallbackValue(>[]); registerFallbackValue(FakeTimelineService()); + registerFallbackValue( + ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'), + ); }); setUp(() async { @@ -121,14 +120,15 @@ void main() { deepLinkAsset = _localAsset(id: 'local-1'); deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink); - when(() => router.removeWhere(any())).thenReturn(false); - when(() => router.push(any())).thenAnswer((_) async => null); + when(() => router.replaceAll(any())).thenAnswer((_) async {}); container = ProviderContainer( overrides: [ viewIntentServiceProvider.overrideWithValue(viewIntentService), viewIntentAssetResolverProvider.overrideWithValue(resolver), appRouterProvider.overrideWithValue(router), + // viewIntentMainTimelineReadyProvider reads both of these to compute + // its ready state — without them wait() never resolves. timelineServiceProvider.overrideWithValue(deepLinkTimelineService), timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])), authProvider.overrideWith((ref) { @@ -154,13 +154,7 @@ void main() { await handler.handle(payload); expect(container.read(viewIntentPendingProvider), payload); - verifyNever( - () => resolver.resolve( - payload, - timelineUsers: any(named: 'timelineUsers'), - mainTimelineService: any(named: 'mainTimelineService'), - ), - ); + verifyNever(() => resolver.resolve(any())); }); testWidgets('flushDeferredViewIntent waits for main timeline readiness before flushing pending attachment', ( @@ -170,27 +164,15 @@ void main() { container.read(viewIntentPendingProvider.notifier).defer(payload); authNotifier.setAuthenticated(true); - when( - () => resolver.resolve( - payload, - timelineUsers: any(named: 'timelineUsers'), - mainTimelineService: any(named: 'mainTimelineService'), - ), - ).thenAnswer((_) async { - return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService, initialIndex: 0); + when(() => resolver.resolve(payload)).thenAnswer((_) async { + return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService); }); unawaited(handler.flushDeferredViewIntent()); await tester.pump(); expect(container.read(viewIntentPendingProvider), payload); - verifyNever( - () => resolver.resolve( - payload, - timelineUsers: any(named: 'timelineUsers'), - mainTimelineService: any(named: 'mainTimelineService'), - ), - ); + verifyNever(() => resolver.resolve(any())); container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce(); await tester.pump(); @@ -198,21 +180,13 @@ void main() { await tester.idle(); expect(container.read(viewIntentPendingProvider), isNull); - verify( - () => resolver.resolve(payload, timelineUsers: ['user-1'], mainTimelineService: deepLinkTimelineService), - ).called(1); + verify(() => resolver.resolve(payload)).called(1); }); test('flushDeferredViewIntent does nothing when there is no pending attachment', () async { await handler.flushDeferredViewIntent(); - verifyNever( - () => resolver.resolve( - payload, - timelineUsers: any(named: 'timelineUsers'), - mainTimelineService: any(named: 'mainTimelineService'), - ), - ); + verifyNever(() => resolver.resolve(any())); }); test('onAppResumed cleans stale temp files when no attachment is present', () async { @@ -221,13 +195,7 @@ void main() { await handler.onAppResumed(); expect(viewIntentService.cleanupStaleTempFilesCalls, 1); - verifyNever( - () => resolver.resolve( - payload, - timelineUsers: any(named: 'timelineUsers'), - mainTimelineService: any(named: 'mainTimelineService'), - ), - ); + verifyNever(() => resolver.resolve(any())); }); test('onAppResumed does not clean stale temp files while pending attachment exists', () async { @@ -237,26 +205,13 @@ void main() { await handler.onAppResumed(); expect(viewIntentService.cleanupStaleTempFilesCalls, 0); - verifyNever( - () => resolver.resolve( - payload, - timelineUsers: any(named: 'timelineUsers'), - mainTimelineService: any(named: 'mainTimelineService'), - ), - ); + verifyNever(() => resolver.resolve(any())); }); testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async { viewIntentService.consumedAttachment = payload; - when( - () => resolver.resolve( - payload, - timelineUsers: any(named: 'timelineUsers'), - mainTimelineService: any(named: 'mainTimelineService'), - ), - ).thenAnswer( - (_) async => - ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService, initialIndex: 0), + when(() => resolver.resolve(payload)).thenAnswer( + (_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService), ); unawaited(handler.onAppResumed()); @@ -265,9 +220,15 @@ void main() { await tester.pump(); await tester.idle(); - verify( - () => resolver.resolve(payload, timelineUsers: ['user-1'], mainTimelineService: deepLinkTimelineService), - ).called(1); + verify(() => resolver.resolve(payload)).called(1); + // Routes the user to [TabShell, AssetViewer] so back-press lands on the + // main timeline — mirrors the home-screen widget navigation pattern. + final captured = verify(() => router.replaceAll(captureAny())).captured; + expect(captured, hasLength(1)); + final routes = captured.single as List>; + expect(routes, hasLength(2)); + expect(routes[0].routeName, TabShellRoute.name); + expect(routes[1].routeName, AssetViewerRoute.name); }); } diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index b6df1850c2..38d2f71f88 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -5,39 +5,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/timeline.repository.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/services/view_intent_asset_resolver.service.dart'; import 'package:mocktail/mocktail.dart'; import '../infrastructure/repository.mock.dart'; -class MockTimelineRepository extends Mock implements DriftTimelineRepository {} - class MockTimelineFactory extends Mock implements TimelineFactory {} -class MockNativeSyncApi extends Mock implements NativeSyncApi {} - void main() { late MockDriftLocalAssetRepository mockLocalAssetRepository; - late MockNativeSyncApi nativeSyncApi; - late MockTimelineRepository timelineRepository; late MockTimelineFactory timelineFactory; - late TimelineService mainTimelineService; late List createdTimelineServices; late ProviderContainer container; - setUp(() async { + setUp(() { mockLocalAssetRepository = MockDriftLocalAssetRepository(); - nativeSyncApi = MockNativeSyncApi(); - timelineRepository = MockTimelineRepository(); timelineFactory = MockTimelineFactory(); createdTimelineServices = []; - mainTimelineService = await _setMainTimelineService(const [], createdTimelineServices); when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) { final assets = List.from(invocation.positionalArguments[0] as List); @@ -49,8 +36,6 @@ void main() { container = ProviderContainer( overrides: [ localAssetRepository.overrideWith((ref) => mockLocalAssetRepository), - nativeSyncApiProvider.overrideWith((ref) => nativeSyncApi), - timelineRepositoryProvider.overrideWith((ref) => timelineRepository), timelineFactoryProvider.overrideWith((ref) => timelineFactory), ], ); @@ -63,124 +48,53 @@ void main() { }); }); - test('resolves main timeline asset by local id without hashing', () async { - final localAsset = _localAsset(id: 'local-1'); - final mainAsset = _remoteAsset(id: 'remote-1', localId: 'local-1', checksum: 'checksum-1'); - mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices); - - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); - when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => 0); - - final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService); - - expect(result.asset, same(mainAsset)); - expect(result.timelineService, same(mainTimelineService)); - expect(result.initialIndex, 0); - expect(result.viewIntentFilePath, isNull); - verifyNever(() => nativeSyncApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); - verifyNever(() => timelineRepository.getMainTimelineIndexByChecksum(any(), any())); - }); - - test('falls back to checksum from local db when local id is not in main timeline', () async { + test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async { final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1'); - final mainAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); - mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices); - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); - when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null); - when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-1')).thenAnswer((_) async => 0); - final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService); - - expect(result.asset, same(mainAsset)); - expect(result.timelineService, same(mainTimelineService)); - verifyNever(() => nativeSyncApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))); - }); - - test('computes checksum for local asset when db checksum is missing', () async { - final localAsset = _localAsset(id: 'local-1', checksum: null); - final mainAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); - mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices); - - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); - when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null); - when( - () => nativeSyncApi.hashAssets(['local-1'], allowNetworkAccess: false), - ).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]); - when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-1')).thenAnswer((_) async => 0); - - final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService); - - expect(result.asset, same(mainAsset)); - expect(result.timelineService, same(mainTimelineService)); - verify(() => nativeSyncApi.hashAssets(['local-1'], allowNetworkAccess: false)).called(1); - }); - - test('returns deep-link local asset when no main timeline match is found', () async { - final localAsset = _localAsset(id: 'local-1', checksum: null); - - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); - when(() => timelineRepository.getMainTimelineIndexByLocalId(['user-1'], 'local-1')).thenAnswer((_) async => null); - when(() => nativeSyncApi.hashAssets(['local-1'], allowNetworkAccess: false)).thenThrow(Exception('hash failed')); - - final result = await _resolve(container, _payload(localAssetId: 'local-1'), mainTimelineService); + final result = await _resolve(container, _payload(localAssetId: 'local-1')); expect(result.asset, equals(localAsset)); expect(result.timelineService.origin, TimelineOrigin.deepLink); - expect(result.initialIndex, 0); - expect(result.viewIntentFilePath, isNull); + expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed'); }); - test('matches path-only attachment to main timeline by checksum', () async { - final mainAsset = _remoteAsset(id: 'remote-2', checksum: 'checksum-2'); - mainTimelineService = await _setMainTimelineService([mainAsset], createdTimelineServices); + test('returns transient asset with temp file path when localAssetId has no DB row', () async { + when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null); - when( - () => nativeSyncApi.hashFiles(['/tmp/incoming.jpg']), - ).thenAnswer((_) async => [HashResult(assetId: '/tmp/incoming.jpg', hash: 'checksum-2')]); - when(() => timelineRepository.getMainTimelineIndexByChecksum(['user-1'], 'checksum-2')).thenAnswer((_) async => 0); + final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg')); - final result = await _resolve( - container, - _payload(path: '/tmp/incoming.jpg', localAssetId: null), - mainTimelineService, - ); - - expect(result.asset, same(mainAsset)); - expect(result.timelineService, same(mainTimelineService)); - expect(result.viewIntentFilePath, isNull); + expect(result.asset, isA()); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.viewIntentFilePath, '/tmp/incoming.jpg'); }); - test('returns transient deep-link asset for unmatched path-only attachment', () async { - when(() => nativeSyncApi.hashFiles(['/tmp/incoming.webp'])).thenAnswer((_) async => const []); - + test('returns transient asset for path-only attachment', () async { final result = await _resolve( container, - _payload(path: '/tmp/incoming.webp', localAssetId: null, mimeType: 'image/webp'), - mainTimelineService, + _payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'), ); expect(result.asset, isA()); expect(result.timelineService.origin, TimelineOrigin.deepLink); - expect(result.initialIndex, 0); expect(result.viewIntentFilePath, '/tmp/incoming.webp'); final asset = result.asset as LocalAsset; expect(asset.localId, startsWith('-')); expect(asset.name, 'incoming.webp'); - expect(asset.checksum, isNull); expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated); }); + + test('throws when neither localAssetId nor path is provided', () async { + await expectLater( + _resolve(container, _payload(localAssetId: null, path: null)), + throwsA(isA()), + ); + }); } -Future _resolve( - ProviderContainer container, - ViewIntentPayload payload, - TimelineService mainTimelineService, -) { - return container - .read(viewIntentAssetResolverProvider) - .resolve(payload, timelineUsers: const ['user-1'], mainTimelineService: mainTimelineService); +Future _resolve(ProviderContainer container, ViewIntentPayload payload) { + return container.read(viewIntentAssetResolverProvider).resolve(payload); } ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) { @@ -200,20 +114,6 @@ LocalAsset _localAsset({required String id, String? checksum}) { ); } -RemoteAsset _remoteAsset({required String id, String? localId, String? checksum}) { - return RemoteAsset( - id: id, - localId: localId, - ownerId: 'user-1', - name: '$id.jpg', - checksum: checksum, - type: AssetType.image, - createdAt: DateTime(2026, 4, 20), - updatedAt: DateTime(2026, 4, 20), - isEdited: false, - ); -} - TimelineService _timelineServiceFromAssets(List assets, TimelineOrigin origin) { return TimelineService(( assetSource: (index, count) async => assets.skip(index).take(count).toList(), @@ -221,22 +121,3 @@ TimelineService _timelineServiceFromAssets(List assets, TimelineOrigi origin: origin, )); } - -Future _createReadyTimelineService(List assets, TimelineOrigin origin) async { - final timelineService = _timelineServiceFromAssets(assets, origin); - - for (var i = 0; i < 20 && !timelineService.isReady; i++) { - await Future.delayed(Duration.zero); - } - - return timelineService; -} - -Future _setMainTimelineService( - List assets, - List createdTimelineServices, -) async { - final timelineService = await _createReadyTimelineService(assets, TimelineOrigin.main); - createdTimelineServices.add(timelineService); - return timelineService; -}