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..a1e1fea3dd 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 @@ -1,14 +1,14 @@ package app.alextran.immich.viewintent import android.app.Activity -import android.content.ContentUris import android.content.Context import android.content.Intent import android.net.Uri import android.provider.DocumentsContract +import android.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 @@ -26,7 +26,7 @@ private const val TAG = "ViewIntentPlugin" class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi { private var context: Context? = null private var activity: Activity? = null - private var pendingIntent: Intent? = null + private var unconsumedIntent: Intent? = null private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -42,7 +42,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity - pendingIntent = binding.activity.intent + unconsumedIntent = binding.activity.intent binding.addOnNewIntentListener(this) } @@ -59,7 +59,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL } override fun onNewIntent(intent: Intent): Boolean { - pendingIntent = intent + unconsumedIntent = intent return false } @@ -68,7 +68,7 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL callback(Result.success(null)) return } - val intent = pendingIntent ?: activity?.intent + val intent = unconsumedIntent ?: activity?.intent if (intent?.action != Intent.ACTION_VIEW) { callback(Result.success(null)) @@ -83,7 +83,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 @@ -112,108 +112,40 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL } private fun consumeViewIntent(currentIntent: Intent) { - pendingIntent = Intent(currentIntent).apply { + unconsumedIntent = Intent(currentIntent).apply { action = null data = null type = null } - activity?.intent = pendingIntent + activity?.intent = unconsumedIntent } 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) + ?: 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 - } - - val parsed = docId.substringAfter(':', docId) - if (parsed.all(Char::isDigit)) { - return parsed - } - - return MediaStoreUtils.resolveLocalIdByRelativePath(context, parsed, mimeType) + if (docId.isBlank() || docId.startsWith("raw:")) return null + docId.substringAfter(':', docId).toLongOrNull()?.toString() } catch (e: Exception) { Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e) - return null - } - } - - private fun tryParseContentUriId(uri: Uri): String? { - return try { - val parsed = ContentUris.parseId(uri) - if (parsed >= 0) parsed.toString() else null - } catch (e: Exception) { - Log.w(TAG, "Failed to parse local asset id from content URI: $uri", e) null } } - private fun tryParseLastPathSegmentId(uri: Uri): String? { - val segment = uri.lastPathSegment ?: return null - return if (segment.all(Char::isDigit)) segment else null + private fun tryParseContentUriId(uri: Uri): String? { + val id = uri.lastPathSegment?.toLongOrNull() ?: return null + return if (id >= 0) id.toString() else null } private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? { return try { - val 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 +157,45 @@ 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 -> return null + } + return try { + context.contentResolver + .query( + tableUri, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?", + arrayOf(displayName, size.toString()), + "${MediaStore.MediaColumns.DATE_MODIFIED} DESC", + )?.use { cursor -> + if (!cursor.moveToFirst()) return null + val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + if (idIndex < 0) return null + cursor.getLong(idIndex).toString() + } + } catch (_: Exception) { + null + } + } } diff --git a/mobile/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..e6903defeb 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -318,13 +318,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() diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index adcc1409f6..5779ee1053 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -17,8 +17,6 @@ typedef TimelineBucketSource = Stream> Function(); typedef TimelineQuery = ({TimelineAssetSource assetSource, TimelineBucketSource bucketSource, TimelineOrigin origin}); -enum TimelineStatus { uninitialized, ready, disposed } - enum TimelineOrigin { main, localAlbum, @@ -103,13 +101,9 @@ class TimelineService { int _bufferOffset = 0; List _buffer = []; StreamSubscription? _bucketSubscription; - final StreamController _statusController = StreamController.broadcast(); int _totalAssets = 0; int get totalAssets => _totalAssets; - TimelineStatus _status = TimelineStatus.uninitialized; - TimelineStatus get status => _status; - bool get isReady => _status == TimelineStatus.ready; TimelineService(TimelineQuery query) : this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin); @@ -145,17 +139,12 @@ class TimelineService { // change the state's total assets count only after the buffer is reloaded _totalAssets = totalAssets; - if (_status == TimelineStatus.uninitialized) { - _status = TimelineStatus.ready; - _statusController.add(_status); - } EventStream.shared.emit(const TimelineReloadEvent()); }); }); } Stream> Function() get watchBuckets => _bucketSource; - Stream watchStatus() => _statusController.stream; Future> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count)); @@ -258,12 +247,5 @@ class TimelineService { _bucketSubscription = null; _buffer = []; _bufferOffset = 0; - if (_status != TimelineStatus.disposed) { - _status = TimelineStatus.disposed; - if (!_statusController.isClosed) { - _statusController.add(_status); - } - } - await _statusController.close(); } } 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..9c4501b665 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -678,36 +678,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } } - - Future getMainTimelineIndexByChecksum(List userIds, String checksum) async { - if (userIds.isEmpty) { - return null; - } - final result = await _db.mergedAssetDrift - .mergedAssetIndexByChecksum(userIds: userIds, checksum: checksum) - .getSingleOrNull(); - return result; - } - - Future getMainTimelineIndexByLocalId(List userIds, String localAssetId) async { - if (userIds.isEmpty) { - return null; - } - final result = await _db.mergedAssetDrift - .mergedAssetIndexByLocalId(userIds: userIds, localAssetId: localAssetId) - .getSingleOrNull(); - return result; - } - - 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/local_image_api.g.dart b/mobile/lib/platform/local_image_api.g.dart index fbd0876735..3495cc782f 100644 --- a/mobile/lib/platform/local_image_api.g.dart +++ b/mobile/lib/platform/local_image_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', @@ -26,6 +34,8 @@ Object? _extractReplyValueOrThrow(List? replyList, String channelName, return replyList.firstOrNull; } + + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -52,50 +62,35 @@ class LocalImageApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. LocalImageApi({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(); final String pigeonVar_messageChannelSuffix; - Future?> requestImage( - String assetId, { - required int requestId, - required int width, - required int height, - required bool isVideo, - required bool preferEncoded, - }) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; + Future?> requestImage(String assetId, {required int requestId, required int width, required int height, required bool isVideo, required bool preferEncoded, }) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([ - assetId, - requestId, - width, - height, - isVideo, - preferEncoded, - ]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([assetId, requestId, width, height, isVideo, preferEncoded]); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: true, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; return (pigeonVar_replyValue as Map?)?.cast(); } Future cancelRequest(int requestId) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -104,12 +99,16 @@ class LocalImageApi { final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId]); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true); + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; } Future> getThumbhash(String thumbhash) async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -119,10 +118,11 @@ class LocalImageApi { 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(); } } 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/platform/view_intent_api.g.dart b/mobile/lib/platform/view_intent_api.g.dart index d457c249de..8cee1e2ef8 100644 --- a/mobile/lib/platform/view_intent_api.g.dart +++ b/mobile/lib/platform/view_intent_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,8 +96,13 @@ int _deepHash(Object? value) { return value.hashCode; } + class ViewIntentPayload { - ViewIntentPayload({this.path, required this.mimeType, this.localAssetId}); + ViewIntentPayload({ + this.path, + required this.mimeType, + this.localAssetId, + }); String? path; @@ -96,12 +111,15 @@ class ViewIntentPayload { String? localAssetId; List _toList() { - return [path, mimeType, localAssetId]; + return [ + path, + mimeType, + localAssetId, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static ViewIntentPayload decode(Object result) { result as List; @@ -121,9 +139,7 @@ class ViewIntentPayload { if (identical(this, other)) { return true; } - return _deepEquals(path, other.path) && - _deepEquals(mimeType, other.mimeType) && - _deepEquals(localAssetId, other.localAssetId); + return _deepEquals(path, other.path) && _deepEquals(mimeType, other.mimeType) && _deepEquals(localAssetId, other.localAssetId); } @override @@ -131,6 +147,7 @@ class ViewIntentPayload { int get hashCode => _deepHash([runtimeType, ..._toList()]); } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -138,7 +155,7 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is ViewIntentPayload) { + } else if (value is ViewIntentPayload) { buffer.putUint8(129); writeValue(buffer, value.encode()); } else { @@ -162,8 +179,8 @@ class ViewIntentHostApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -171,8 +188,7 @@ class ViewIntentHostApi { final String pigeonVar_messageChannelSuffix; Future consumeViewIntent() async { - final pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -182,10 +198,11 @@ class ViewIntentHostApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: true, - ); + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; return pigeonVar_replyValue as ViewIntentPayload?; } } diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 9eae007a36..5ec946858d 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -1,25 +1,16 @@ -import 'dart:async'; - import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart'; @RoutePage() -class MainTimelinePage extends HookConsumerWidget { +class MainTimelinePage extends ConsumerWidget { const MainTimelinePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - useEffect(() { - unawaited(Future(() => ref.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce())); - return null; - }, const []); - final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); return Timeline( topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), 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..599e11d467 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -8,13 +8,12 @@ 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/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_ui/immich_ui.dart'; @@ -61,36 +60,32 @@ class UploadActionButton extends ConsumerWidget { } var success = false; - String? uploadedRemoteAssetId; if (!isTimeline && viewerIntentFilePath != null) { + final viewIntentService = ref.read(viewIntentServiceProvider); + viewIntentService.markUploadActive(viewerIntentFilePath); var hasError = false; - await ref - .read(foregroundUploadServiceProvider) - .uploadShareIntent( - [File(viewerIntentFilePath)], - onSuccess: (_, remoteAssetId) { - uploadedRemoteAssetId = remoteAssetId; - }, - onError: (fileId, errorMessage) { - hasError = true; - }, - ); + try { + await ref + .read(foregroundUploadServiceProvider) + .uploadShareIntent( + [File(viewerIntentFilePath)], + onError: (_, _) { + hasError = true; + }, + ); + } finally { + await viewIntentService.markUploadInactive(viewerIntentFilePath); + } success = !hasError; } else { final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); success = result.success; - 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_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index b220049d94..88b520969b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; @@ -328,9 +327,7 @@ class _AssetPageState extends ConsumerState { required String? localFilePath, }) { final size = context.sizeData; - final imageProvider = localFilePath != null - ? FileImage(File(localFilePath)) - : getFullImageProvider(asset, size: size); + final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath); if (asset.isImage && !isPlayingMotionVideo) { return PhotoView( 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..c8d8f63fa9 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'; @@ -65,7 +64,18 @@ class AssetViewer extends ConsumerStatefulWidget { ConsumerState createState() => _AssetViewerState(); static void setAsset(WidgetRef ref, BaseAsset asset) { - prepareAssetViewerState(ref.read(assetViewerProvider.notifier), asset); + ref.read(assetViewerProvider.notifier).reset(); + + // Hide controls by default for videos + if (asset.isVideo) { + ref.read(assetViewerProvider.notifier).setControls(false); + } + + _setAsset(ref, asset); + } + + static void _setAsset(WidgetRef ref, BaseAsset asset) { + ref.read(assetViewerProvider.notifier).setAsset(asset); } } @@ -79,8 +89,6 @@ class _AssetViewerState extends ConsumerState { StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; - MainTimelineHandoffCoordinator? _mainTimelineHandoffCoordinator; - bool _disposeStarted = false; void _onTapNavigate(int direction) { final page = _pageController.page?.toInt(); @@ -99,9 +107,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) { @@ -118,8 +123,6 @@ class _AssetViewerState extends ConsumerState { @override void dispose() { - _disposeStarted = true; - _mainTimelineHandoffCoordinator?.cancel(); _pageController.dispose(); _preloader.dispose(); _reloadSubscription?.cancel(); @@ -157,17 +160,14 @@ class _AssetViewerState extends ConsumerState { } void _onAssetChanged(int index) async { - if (!mounted) { - return; - } _currentPage = index; final asset = await ref.read(timelineServiceProvider).getAssetAsync(index); - if (!mounted || asset == null) { + if (asset == null) { return; } - ref.read(assetViewerProvider.notifier).setAsset(asset); + AssetViewer._setAsset(ref, asset); _preloader.preload(index, context.sizeData); _handleCasting(); _stackChildrenKeepAlive?.close(); @@ -203,9 +203,6 @@ class _AssetViewerState extends ConsumerState { } void _onEvent(Event event) { - if (!mounted || _disposeStarted) { - return; - } switch (event) { case TimelineReloadEvent(): _onTimelineReloadEvent(); @@ -229,20 +226,13 @@ class _AssetViewerState extends ConsumerState { void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); final totalAssets = timelineService.totalAssets; - final currentAsset = ref.read(assetViewerProvider).currentAsset; - final isViewerTransitionInProgress = ref.read( - assetViewerProvider.select((value) => value.isViewerTransitionInProgress), - ); - - if (isViewerTransitionInProgress) { - return; - } if (totalAssets == 0) { context.maybePop(); return; } + final currentAsset = ref.read(assetViewerProvider).currentAsset; final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null; final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1); diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 9364fdd091..f105cc56f1 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:ui' as ui; import 'package:async/async.dart'; @@ -146,10 +147,17 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } } -ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) { +ImageProvider getFullImageProvider( + BaseAsset asset, { + Size size = const Size(1080, 1920), + bool edited = true, + String? localFilePath, +}) { // Create new provider and cache it final ImageProvider provider; - if (_shouldUseLocalAsset(asset)) { + if (localFilePath != null) { + provider = FileImage(File(localFilePath)); + } else if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); } else { diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index 1aea1cfd3f..9f3b05b9b0 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -10,7 +10,6 @@ class AssetViewerState { final bool isZoomed; final BaseAsset? currentAsset; final int stackIndex; - final bool isViewerTransitionInProgress; const AssetViewerState({ this.backgroundOpacity = 1.0, @@ -19,7 +18,6 @@ class AssetViewerState { this.isZoomed = false, this.currentAsset, this.stackIndex = 0, - this.isViewerTransitionInProgress = false, }); AssetViewerState copyWith({ @@ -29,7 +27,6 @@ class AssetViewerState { bool? isZoomed, BaseAsset? currentAsset, int? stackIndex, - bool? isViewerTransitionInProgress, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, @@ -38,7 +35,6 @@ class AssetViewerState { isZoomed: isZoomed ?? this.isZoomed, currentAsset: currentAsset ?? this.currentAsset, stackIndex: stackIndex ?? this.stackIndex, - isViewerTransitionInProgress: isViewerTransitionInProgress ?? this.isViewerTransitionInProgress, ); } @@ -61,8 +57,7 @@ class AssetViewerState { other.showingControls == showingControls && other.isZoomed == isZoomed && other.currentAsset == currentAsset && - other.stackIndex == stackIndex && - other.isViewerTransitionInProgress == isViewerTransitionInProgress; + other.stackIndex == stackIndex; } @override @@ -72,8 +67,7 @@ class AssetViewerState { showingControls.hashCode ^ isZoomed.hashCode ^ currentAsset.hashCode ^ - stackIndex.hashCode ^ - isViewerTransitionInProgress.hashCode; + stackIndex.hashCode; } class AssetViewerStateNotifier extends Notifier { @@ -143,28 +137,10 @@ class AssetViewerStateNotifier extends Notifier { } state = state.copyWith(stackIndex: index); } - - void setViewerTransitionInProgress(bool isInProgress) { - if (isInProgress == state.isViewerTransitionInProgress) { - return; - } - state = state.copyWith(isViewerTransitionInProgress: isInProgress); - } } final assetViewerProvider = NotifierProvider(AssetViewerStateNotifier.new); -void prepareAssetViewerState(AssetViewerStateNotifier notifier, BaseAsset asset) { - notifier.reset(); - - // Hide controls by default for videos before the viewer is shown. - if (asset.isVideo) { - notifier.setControls(false); - } - - notifier.setAsset(asset); -} - final _watchedCurrentAssetProvider = StreamProvider((ref) { ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); final asset = ref.read(assetViewerProvider).currentAsset; 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/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index dd0a2d692e..9f2fdec519 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -41,23 +41,3 @@ final timelineUsersProvider = StreamProvider>((ref) { return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId); }); - -final timelineStatusProvider = StreamProvider.autoDispose.family(( - ref, - timelineService, -) async* { - yield timelineService.status; - yield* timelineService.watchStatus(); -}); - -Future waitForTimelineReady(TimelineService timelineService, Duration timeout) { - if (timelineService.isReady) { - return Future.value(); - } - - return timelineService - .watchStatus() - .firstWhere((status) => status == TimelineStatus.ready) - .timeout(timeout) - .then((_) {}); -} 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..c00ff38648 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -1,16 +1,13 @@ 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'; import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; @@ -55,18 +52,8 @@ class AndroidViewIntentHandler implements ViewIntentHandler { } Future _flushPending() async { - if (_ref.read(viewIntentPendingProvider) == null) { - return; - } - - try { - await _ref.read(viewIntentMainTimelineReadyProvider.notifier).wait(timeout: const Duration(seconds: 3)); - } catch (_) { - return; - } - final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh(); - _logger.info('flushPending, pendingAttachment:$pendingAttachment}'); + _logger.info('flushPending, pendingAttachment:$pendingAttachment'); if (pendingAttachment != null) { await handle(pendingAttachment); } @@ -83,66 +70,34 @@ 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); - unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath)); - } else { - _ref.read(viewIntentFilePathProvider.notifier).clear(); - unawaited(_viewIntentService.cleanupManagedTempFile()); - } - - unawaited(_router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService))); - await _waitForNextFrame(); - } finally { - notifier.setViewerTransitionInProgress(false); + notifier.reset(); + if (asset.isVideo) { + notifier.setControls(false); } - } + notifier.setAsset(asset); - Future _waitForNextFrame() { - final completer = Completer(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!completer.isCompleted) { - completer.complete(); - } - }); - return completer.future; + if (viewIntentFilePath != null) { + _ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath); + unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath)); + } else { + _ref.read(viewIntentFilePathProvider.notifier).clear(); + unawaited(_viewIntentService.cleanupManagedTempFile()); + } + + await _router.replaceAll([ + const TabShellRoute(), + AssetViewerRoute(initialIndex: 0, timelineService: timelineService), + ]); } } diff --git a/mobile/lib/providers/view_intent/view_intent_main_timeline_ready.provider.dart b/mobile/lib/providers/view_intent/view_intent_main_timeline_ready.provider.dart deleted file mode 100644 index a6279503eb..0000000000 --- a/mobile/lib/providers/view_intent/view_intent_main_timeline_ready.provider.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; - -final viewIntentMainTimelineReadyProvider = NotifierProvider( - ViewIntentMainTimelineReadyNotifier.new, -); - -class ViewIntentMainTimelineReadyNotifier extends Notifier { - Completer? _readyCompleter; - bool _hasSeenMainTimeline = false; - bool _hasTimelineUsers = false; - bool _isTimelineReady = false; - - @override - bool build() { - _readyCompleter ??= Completer(); - - final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull; - final timelineService = ref.watch(timelineServiceProvider); - final timelineStatus = ref.watch(timelineStatusProvider(timelineService)).valueOrNull ?? timelineService.status; - - _hasTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty; - _isTimelineReady = timelineStatus == TimelineStatus.ready; - - final isReady = _computeReady(); - _completeWaitersIfReady(isReady); - return isReady; - } - - Future wait({required Duration timeout}) { - if (state) { - return Future.value(); - } - - return _readyCompleter!.future.timeout(timeout); - } - - void markMountedOnce() { - _hasSeenMainTimeline = true; - final isReady = _computeReady(); - state = isReady; - _completeWaitersIfReady(isReady); - } - - bool _computeReady() => _hasSeenMainTimeline && _hasTimelineUsers && _isTimelineReady; - - void _completeWaitersIfReady(bool isReady) { - if (isReady) { - if (!(_readyCompleter?.isCompleted ?? true)) { - _readyCompleter?.complete(); - } - } else if (_readyCompleter?.isCompleted ?? true) { - _readyCompleter = Completer(); - } - } -} diff --git a/mobile/lib/services/view_intent.service.dart b/mobile/lib/services/view_intent.service.dart index 1a03ac9486..f12f89c81a 100644 --- a/mobile/lib/services/view_intent.service.dart +++ b/mobile/lib/services/view_intent.service.dart @@ -11,6 +11,7 @@ class ViewIntentService { final ViewIntentHostApi _viewIntentHostApi; final Future Function() _temporaryDirectory; String? _managedTempFilePath; + final Set _activeUploadPaths = {}; ViewIntentService(this._viewIntentHostApi, {Future Function()? temporaryDirectory}) : _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; @@ -54,6 +55,9 @@ class ViewIntentService { if (!_isManagedTempFile(path)) { return; } + if (_activeUploadPaths.contains(path)) { + return; + } try { final file = File(path); @@ -74,7 +78,9 @@ class ViewIntentService { } final path = entity.path; - if (!_isManagedTempFile(path) || path == _managedTempFilePath) { + if (!_isManagedTempFile(path) || + path == _managedTempFilePath || + _activeUploadPaths.contains(path)) { continue; } @@ -85,6 +91,19 @@ class ViewIntentService { } } + void markUploadActive(String path) { + _activeUploadPaths.add(path); + } + + Future markUploadInactive(String path) async { + if (!_activeUploadPaths.remove(path)) { + return; + } + if (_managedTempFilePath != path) { + await cleanupTempFile(path); + } + } + bool _isManagedTempFile(String path) { return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache'; } diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 13d6473d87..f5c7789149 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -1,270 +1,64 @@ -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; + 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), ), ); 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; - } - } - 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: 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. - // The viewer currently expects a BaseAsset, so this temporary LocalAsset - // 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/mise.toml b/mobile/mise.toml index 01177dca37..70904d30b7 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -29,8 +29,8 @@ run = [ "dart run pigeon --input pigeon/background_worker_lock_api.dart", "dart run pigeon --input pigeon/connectivity_api.dart", "dart run pigeon --input pigeon/network_api.dart", - "dart run pigeon --input pigeon/view_intent_api.dart", - "dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart", + "dart run pigeon --input pigeon/view_intent_api.dart", + "dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart", ] [tasks."codegen:translation"] diff --git a/mobile/pigeon/local_image_api.dart b/mobile/pigeon/local_image_api.dart index eb538d7b1a..46643b7956 100644 --- a/mobile/pigeon/local_image_api.dart +++ b/mobile/pigeon/local_image_api.dart @@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart'; dartOut: 'lib/platform/local_image_api.g.dart', swiftOut: 'ios/Runner/Images/LocalImages.g.swift', swiftOptions: SwiftOptions(includeErrorClass: false), - kotlinOut: - 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'), dartOptions: DartOptions(), dartPackageName: 'immich_mobile', diff --git a/mobile/pigeon/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..f9c2c9d323 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 @@ -10,9 +10,7 @@ import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart'; -import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; @@ -88,8 +86,6 @@ class TestAuthNotifier extends AuthNotifier { } } -bool _pageRoutePredicate(PageRouteInfo route) => false; - final _handlerProvider = Provider((ref) => AndroidViewIntentHandler(ref)); void main() { @@ -107,10 +103,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,16 +118,13 @@ 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), - timelineServiceProvider.overrideWithValue(deepLinkTimelineService), - timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])), authProvider.overrideWith((ref) { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); return authNotifier; @@ -139,7 +133,6 @@ void main() { ); authNotifier = container.read(authProvider.notifier) as TestAuthNotifier; - await container.read(timelineUsersProvider.future); handler = container.read(_handlerProvider); addTearDown(() async { @@ -154,65 +147,31 @@ 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', ( - tester, - ) async { + testWidgets('flushDeferredViewIntent consumes the pending attachment and routes the viewer', (tester) async { authNotifier.setAuthenticated(false); container.read(viewIntentPendingProvider.notifier).defer(payload); authNotifier.setAuthenticated(true); - when( - () => resolver.resolve( - payload, - 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'), - ), - ); - - container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce(); - await tester.pump(); await tester.pump(); 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 +180,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 +190,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 +205,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); }); } @@ -306,10 +252,10 @@ TimelineService _timelineServiceFromAssets(List assets, TimelineOrigi Future _createReadyTimelineService(List assets, TimelineOrigin origin) async { final timelineService = _timelineServiceFromAssets(assets, origin); - - if (!timelineService.isReady) { - await timelineService.watchStatus().firstWhere((status) => status == TimelineStatus.ready); + // Spin a few async ticks so the internal bucket subscription has populated + // the buffer before tests start asserting against totalAssets. + for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) { + await Future.delayed(Duration.zero); } - return timelineService; } diff --git a/mobile/test/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; -} diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index 73f5db9323..7b3d0b85e7 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -74,6 +74,18 @@ void main() { expect(await secondFile.exists(), isFalse); }); + test('cleanupTempFile defers deletion while an upload is active', () async { + final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes'); + + service.markUploadActive(tempFile.path); + await service.cleanupTempFile(tempFile.path); + + expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup'); + + await service.markUploadInactive(tempFile.path); + expect(await tempFile.exists(), isFalse); + }); + test('cleanupTempFile ignores non-managed paths', () async { final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content'); @@ -93,4 +105,15 @@ void main() { expect(await secondFile.exists(), isFalse); expect(await unrelatedFile.exists(), isTrue); }); + + test('cleanupStaleTempFiles skips paths with active uploads', () async { + final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale'); + final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active'); + service.markUploadActive(active.path); + + await service.cleanupStaleTempFiles(); + + expect(await stale.exists(), isFalse); + expect(await active.exists(), isTrue); + }); }