Compare commits

...

45 Commits

Author SHA1 Message Date
shenlong-tanwen 0281de7ff6 cleanup 2026-05-23 05:41:14 +05:30
shenlong-tanwen 43554fc6cf Merge remote-tracking branch 'origin/main' into feature/gallery_app
# Conflicts:
#	mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
#	mobile/ios/Runner/Sync/Messages.g.swift
#	mobile/lib/platform/native_sync_api.g.dart
2026-05-22 22:50:45 +05:30
Peter Ombodi 7015e511e8 Merge remote-tracking branch 'origin/main' into feature/gallery_app 2026-05-14 13:13:46 +03:00
Peter Ombodi 96420bbf04 fix(mobile): resolve merge conflicts 2026-05-14 13:12:59 +03:00
Peter Ombodi f4e275a257 Merge remote-tracking branch 'origin/main' into feature/gallery_app
# Conflicts:
#	mobile/lib/infrastructure/entities/merged_asset.drift.dart
#	mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart
#	mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart
#	mobile/makefile
2026-05-13 18:07:26 +03:00
Peter Ombodi 561fe231ac docs(mobile): clarify view intent fallback asset TODO 2026-04-27 18:41:55 +03:00
Peter Ombodi 6b291c469e fix(mobile): resolve view intent MIME type with fallbacks 2026-04-27 16:28:58 +03:00
Peter Ombodi 7d5be4317f Merge remote-tracking branch 'origin/main' into feature/gallery_app 2026-04-27 16:03:28 +03:00
Peter Ombodi eee3d2ce61 fix(mobile): avoid double pop when canceling upload dialog 2026-04-27 16:02:19 +03:00
Peter Ombodi e2f5308cba refactor(mobile): decouple view intent asset resolver from providers 2026-04-27 13:28:53 +03:00
Peter Ombodi d96cb8d386 Merge remote-tracking branch 'origin/main' into feature/gallery_app 2026-04-27 13:03:53 +03:00
Peter Ombodi 2c9639f18b fix(mobile): wait for main timeline before deferred view intent handoff 2026-04-27 13:02:01 +03:00
Peter Ombodi 880155916f refactor(mobile): share AssetViewer pre-navigation state preparation 2026-04-23 17:15:19 +03:00
Peter Ombodi 84854a8575 fix(mobile): stabilize Android view intent asset resolution and fallback viewer 2026-04-23 16:10:17 +03:00
Peter Ombodi fde0959579 style(mobile): reformat code #2 2026-04-22 14:42:35 +03:00
Peter Ombodi ca203726dc style(mobile): reformat code 2026-04-22 14:01:16 +03:00
Peter Ombodi 5d33870403 refactor(mobile): resolve merge conflicts 2026-04-22 13:51:19 +03:00
Peter Ombodi 0276e86895 Merge remote-tracking branch 'origin/main' into feature/gallery_app
# Conflicts:
#	mobile/android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt
#	mobile/ios/Runner/Sync/Messages.g.swift
#	mobile/lib/infrastructure/entities/merged_asset.drift.dart
#	mobile/lib/platform/native_sync_api.g.dart
2026-04-22 13:46:14 +03:00
Peter Ombodi 90d9d0075a refactor(mobile): use remote asset ids for view intent handoff and simplify resolver 2026-04-22 13:37:42 +03:00
Peter Ombodi 6b7b029562 fix(mobile): hand off deep-link viewer to main timeline after upload
Add MainTimelineHandoffCoordinator to switch the asset viewer to the main timeline once a view-intent asset is uploaded and becomes available, and guard viewer reload/navigation transitions to avoid race conditions and crashes.
2026-04-21 19:00:24 +03:00
Peter Ombodi 7adc568575 refactor(mobile): simplify code 2026-04-20 18:20:04 +03:00
Peter Ombodi f5dd2cfb18 refactor(mobile): move view intent handling behind platform-specific factories 2026-04-20 18:07:39 +03:00
Peter Ombodi 8c143d36ef refactor(mobile): split view intent handler by platform and trigger it from app events 2026-04-20 17:53:48 +03:00
Peter Ombodi 45411f38e8 fix(mobile): flush pending view intents after login navigation 2026-04-20 16:37:19 +03:00
Peter Ombodi 28dda8e2d5 refactor(mobile): lazily materialize view-intent files and clean up temp-file handling 2026-04-20 13:03:55 +03:00
Peter Ombodi dc15af4e69 style(mobile): format files #2 2026-04-17 19:01:17 +03:00
Peter Ombodi 2775a09dc5 style(mobile): format files 2026-04-17 18:47:46 +03:00
Peter Ombodi 80c9796abe Merge remote-tracking branch 'origin/main' into feature/gallery_app 2026-04-17 18:42:28 +03:00
Peter Ombodi 66a3aa27b5 refactor(mobile): resolve merge conflicts
use NativeSyncApi for hash files instead method from removed BackgroundServicePlugin.kt
2026-04-17 18:40:20 +03:00
Peter Ombodi 275c324e8d Merge remote-tracking branch 'origin/main' into feature/gallery_app
# Conflicts:
#	mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
#	mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
#	mobile/lib/main.dart
2026-04-17 16:29:59 +03:00
Peter Ombodi 4354431327 refactor(mobile): move deferred view intents into providers, split view-intent providers, and clean up ACTION_VIEW handling 2026-04-17 16:21:22 +03:00
Peter Ombodi 0d4d59c7e7 refactor(mobile): extract MediaStore utils and resolve view intents via merged assets 2026-04-17 12:43:24 +03:00
Peter Ombodi b3b0b0f576 refactor(mobile): simplify view intent flow and support file-backed ACTION_VIEW assets
remove redundant view intent model/repository layer
handle transient ACTION_VIEW files in viewer/upload flow
clean up managed temp files for fallback assets
2026-04-16 14:34:08 +03:00
Peter Ombodi 4806dc76aa fix(mobile): remove redundant iOS code
update code related to LocalAsset model and asset viewer
2026-04-15 16:42:44 +03:00
Peter Ombodi 719c7d955b Merge branch 'main' into feature/gallery_app 2026-04-09 12:20:31 +03:00
Peter Ombodi 175f8d99de fix(mobile): fix format 2026-02-12 14:00:10 +02:00
Peter Ombodi fb66f53410 test(mobile): add unit tests for view intent pending/flush flow 2026-02-10 18:33:30 +02:00
Peter Ombodi 136379a882 Merge remote-tracking branch 'origin/main' into feature/gallery_app 2026-02-10 18:26:22 +02:00
Peter Ombodi c35c948f63 feat(mobile): add logger 2026-02-10 18:25:02 +02:00
Peter Ombodi bc301a3aac feat(mobile): open ACTION_VIEW fallback in AssetViewer
drop ExternalMediaViewer route
2026-02-10 16:35:27 +02:00
Peter Ombodi 3ab68a4bf8 fix(mobile): proper handling is user authenticated 2026-02-10 15:49:22 +02:00
Peter Ombodi 66c6daeded Merge remote-tracking branch 'origin/main' into feature/gallery_app 2026-02-10 12:27:11 +02:00
Peter Ombodi bb803f13da feat(mobile): fallback to computed checksum for timeline match
- hash local asset on-demand when checksum missing
- search main timeline by localId or checksum before standalone viewer
- persist computed hash into local_asset_entity
2026-02-06 18:09:07 +02:00
Peter Ombodi bda0ceb2e2 Merge remote-tracking branch 'origin/main' into feature/gallery_app
# Conflicts:
#	mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
#	mobile/ios/Runner/AppDelegate.swift
#	mobile/makefile
2026-02-06 12:16:58 +02:00
Peter Ombodi ef80a8e936 feat(mobile): handle Android ACTION_VIEW intent
- add ViewIntent Pigeon API and generated bindings
- implement Android ViewIntentPlugin + iOS no-op host
- route ExternalMediaViewer by ViewIntentAttachment
- buffer pending view intents and flush on user ready/resume
2026-02-05 18:54:59 +02:00
37 changed files with 2282 additions and 272 deletions
@@ -89,6 +89,20 @@
<data android:mimeType="video/*" />
</intent-filter>
<!-- Allow Immich to act as an image viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="image/*" />
</intent-filter>
<!-- Allow Immich to act as a video viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="video/*" />
</intent-filter>
<!-- immich:// URL scheme handling -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -1,6 +1,7 @@
package app.alextran.immich
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
@@ -22,6 +23,7 @@ import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -31,6 +33,11 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
@@ -55,6 +62,7 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
@@ -0,0 +1,292 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.viewintent
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object ViewIntentPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
}
fun floatEquals(a: Float, b: Float): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
}
fun doubleHash(d: Double): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (d == 0.0) 0.0 else d
val bits = java.lang.Double.doubleToLongBits(normalized)
return (bits xor (bits ushr 32)).toInt()
}
fun floatHash(f: Float): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (f == 0.0f) 0.0f else f
return java.lang.Float.floatToIntBits(normalized)
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a === b) {
return true
}
if (a == null || b == null) {
return false
}
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!doubleEquals(a[i], b[i])) return false
}
return true
}
if (a is FloatArray && b is FloatArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!floatEquals(a[i], b[i])) return false
}
return true
}
if (a is Array<*> && b is Array<*>) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!deepEquals(a[i], b[i])) return false
}
return true
}
if (a is List<*> && b is List<*>) {
if (a.size != b.size) return false
val iterA = a.iterator()
val iterB = b.iterator()
while (iterA.hasNext() && iterB.hasNext()) {
if (!deepEquals(iterA.next(), iterB.next())) return false
}
return true
}
if (a is Map<*, *> && b is Map<*, *>) {
if (a.size != b.size) return false
for (entry in a) {
val key = entry.key
var found = false
for (bEntry in b) {
if (deepEquals(key, bEntry.key)) {
if (deepEquals(entry.value, bEntry.value)) {
found = true
break
} else {
return false
}
}
}
if (!found) return false
}
return true
}
if (a is Double && b is Double) {
return doubleEquals(a, b)
}
if (a is Float && b is Float) {
return floatEquals(a, b)
}
return a == b
}
fun deepHash(value: Any?): Int {
return when (value) {
null -> 0
is ByteArray -> value.contentHashCode()
is IntArray -> value.contentHashCode()
is LongArray -> value.contentHashCode()
is DoubleArray -> {
var result = 1
for (item in value) {
result = 31 * result + doubleHash(item)
}
result
}
is FloatArray -> {
var result = 1
for (item in value) {
result = 31 * result + floatHash(item)
}
result
}
is Array<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is List<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is Map<*, *> -> {
var result = 0
for (entry in value) {
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
}
result
}
is Double -> doubleHash(value)
is Float -> floatHash(value)
else -> value.hashCode()
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
/** Generated class from Pigeon that represents data sent in messages. */
data class ViewIntentPayload (
val path: String? = null,
val mimeType: String,
val localAssetId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ViewIntentPayload {
val path = pigeonVar_list[0] as String?
val mimeType = pigeonVar_list[1] as String
val localAssetId = pigeonVar_list[2] as String?
return ViewIntentPayload(path, mimeType, localAssetId)
}
}
fun toList(): List<Any?> {
return listOf(
path,
mimeType,
localAssetId,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as ViewIntentPayload
return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId)
return result
}
}
private open class ViewIntentPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ViewIntentPayload.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ViewIntentPayload -> {
stream.write(129)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ViewIntentHostApi {
fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit)
companion object {
/** The codec used by ViewIntentHostApi. */
val codec: MessageCodec<Any?> by lazy {
ViewIntentPigeonCodec()
}
/** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.consumeViewIntent{ result: Result<ViewIntentPayload?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ViewIntentPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ViewIntentPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,219 @@
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.os.Build
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
private const val TAG = "ViewIntentPlugin"
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
private var context: Context? = null
private var activity: Activity? = null
private var pendingIntent: Intent? = null
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
ioScope.cancel()
context = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
pendingIntent = binding.activity.intent
binding.addOnNewIntentListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onNewIntent(intent: Intent): Boolean {
pendingIntent = intent
return false
}
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
val context = context ?: run {
callback(Result.success(null))
return
}
val intent = pendingIntent ?: activity?.intent
if (intent?.action != Intent.ACTION_VIEW) {
callback(Result.success(null))
return
}
val uri = intent.data
if (uri == null) {
callback(Result.success(null))
return
}
ioScope.launch {
try {
val mimeType = context.contentResolver.getType(uri) ?: intent.type
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
callback(Result.success(null))
return@launch
}
val localAssetId = extractLocalAssetId(context, uri, mimeType)
val tempFilePath = if (localAssetId == null) {
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
callback(Result.success(null))
return@launch
}
} else {
null
}
val payload = ViewIntentPayload(
path = tempFilePath,
mimeType = mimeType,
localAssetId = localAssetId,
)
consumeViewIntent(intent)
callback(Result.success(payload))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
private fun consumeViewIntent(currentIntent: Intent) {
pendingIntent = Intent(currentIntent).apply {
action = null
data = null
type = null
}
activity?.intent = pendingIntent
}
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
return tryExtractDocumentLocalAssetId(context, uri)
?: tryParseContentUriId(uri)
?: tryParseLastPathSegmentId(uri)
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
}
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
return try {
if (!DocumentsContract.isDocumentUri(context, uri)) return null
val docId = DocumentsContract.getDocumentId(uri)
if (docId.isBlank() || docId.startsWith("raw:")) return null
val parsed = docId.substringAfter(':', docId)
if (parsed.isNotEmpty() && parsed.all(Char::isDigit)) parsed else null
} catch (e: Exception) {
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
null
}
}
private fun tryParseContentUriId(uri: Uri): String? {
return try {
val parsed = ContentUris.parseId(uri)
if (parsed >= 0) parsed.toString() else null
} catch (e: Exception) {
Log.w(TAG, "Failed to parse local asset id from content URI: $uri", e)
null
}
}
private fun tryParseLastPathSegmentId(uri: Uri): String? {
val segment = uri.lastPathSegment ?: return null
return if (segment.all(Char::isDigit)) segment else null
}
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
return try {
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: return null
tempFile
} catch (_: Exception) {
null
}
}
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
val (displayName, size) =
try {
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
if (name.isNullOrBlank() || bytes < 0) return null
name to bytes
} ?: return null
} catch (_: Exception) {
return null
}
val tableUri = when {
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Files.getContentUri("external")
}
}
return try {
context.contentResolver
.query(
tableUri,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
arrayOf(displayName, size.toString()),
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
if (idIndex < 0) return null
cursor.getLong(idIndex).toString()
}
} catch (_: Exception) {
null
}
}
}
+1 -1
View File
@@ -318,7 +318,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
}
}
func cancelHashing() {
hashTask?.cancel()
hashTask = nil
@@ -17,6 +17,8 @@ typedef TimelineBucketSource = Stream<List<Bucket>> Function();
typedef TimelineQuery = ({TimelineAssetSource assetSource, TimelineBucketSource bucketSource, TimelineOrigin origin});
enum TimelineStatus { uninitialized, ready, disposed }
enum TimelineOrigin {
main,
localAlbum,
@@ -101,9 +103,13 @@ class TimelineService {
int _bufferOffset = 0;
List<BaseAsset> _buffer = [];
StreamSubscription? _bucketSubscription;
final StreamController<TimelineStatus> _statusController = StreamController<TimelineStatus>.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);
@@ -139,12 +145,17 @@ 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<List<Bucket>> Function() get watchBuckets => _bucketSource;
Stream<TimelineStatus> watchStatus() => _statusController.stream;
Future<List<BaseAsset>> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count));
@@ -247,5 +258,12 @@ class TimelineService {
_bucketSubscription = null;
_buffer = [];
_bufferOffset = 0;
if (_status != TimelineStatus.disposed) {
_status = TimelineStatus.disposed;
if (!_statusController.isClosed) {
_statusController.add(_status);
}
}
await _statusController.close();
}
}
@@ -678,6 +678,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
}
List<Bucket> _generateBuckets(int count) {
+3
View File
@@ -24,6 +24,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -128,6 +129,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
case AppLifecycleState.resumed:
dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume();
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break;
case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive");
@@ -233,6 +235,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
}
});
ref.read(viewIntentHandlerProvider).init();
ref.read(shareIntentUploadProvider.notifier).init();
}
@@ -0,0 +1,35 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart';
extension ViewIntentPayloadX on ViewIntentPayload {
String get fileName {
final resolvedPath = path;
if (resolvedPath != null && resolvedPath.isNotEmpty) {
return basename(resolvedPath);
}
return localAssetId ?? 'view_intent_asset';
}
bool get isImage => mimeType.toLowerCase().startsWith('image/');
bool get isVideo => mimeType.toLowerCase().startsWith('video/');
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
final normalizedMimeType = mimeType.toLowerCase();
if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') {
return AssetPlaybackStyle.imageAnimated;
}
final normalizedPath = path?.toLowerCase();
if (normalizedPath != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) {
return AssetPlaybackStyle.imageAnimated;
}
return AssetPlaybackStyle.image;
}
}
@@ -17,6 +17,7 @@ import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
@@ -314,6 +315,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -328,6 +330,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
await viewIntentHandler.flushDeferredViewIntent();
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
+170 -126
View File
@@ -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<Object?>? replyList, String channelName, {required bool isNullValid}) {
Object? _extractReplyValueOrThrow(
List<Object?>? 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<Object?>;
@@ -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<Object?> _toList() {
return <Object?>[id, name, updatedAt, isCloud, assetCount];
return <Object?>[
id,
name,
updatedAt,
isCloud,
assetCount,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static PlatformAlbum decode(Object result) {
result as List<Object?>;
@@ -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<String, List<String>> assetAlbums;
List<Object?> _toList() {
return <Object?>[hasChanges, updates, deletes, assetAlbums];
return <Object?>[
hasChanges,
updates,
deletes,
assetAlbums,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static SyncDelta decode(Object result) {
result as List<Object?>;
@@ -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<Object?> _toList() {
return <Object?>[assetId, error, hash];
return <Object?>[
assetId,
error,
hash,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static HashResult decode(Object result) {
result as List<Object?>;
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<Object?> _toList() {
return <Object?>[assetId, error, cloudId];
return <Object?>[
assetId,
error,
cloudId,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static CloudIdResult decode(Object result) {
result as List<Object?>;
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(<Object?>[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<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -461,8 +493,7 @@ class NativeSyncApi {
final String pigeonVar_messageChannelSuffix;
Future<bool> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -472,16 +503,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as bool;
}
Future<SyncDelta> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -491,16 +522,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as SyncDelta;
}
Future<void> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -509,12 +540,16 @@ class NativeSyncApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
Future<void> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -523,12 +558,16 @@ class NativeSyncApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
Future<List<String>> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -538,16 +577,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
}
Future<List<PlatformAlbum>> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -557,16 +596,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
}
Future<int> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -576,16 +615,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as int;
}
Future<List<PlatformAsset>> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -595,16 +634,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
}
Future<List<HashResult>> hashAssets(List<String> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -614,16 +653,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
}
Future<void> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -632,12 +671,16 @@ class NativeSyncApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
Future<Map<String, List<PlatformAsset>>> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -647,16 +690,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
}
Future<bool> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -666,16 +709,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as bool;
}
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> 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<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -685,10 +728,11 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
}
}
+191
View File
@@ -0,0 +1,191 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
}
if (a is double && b is double) {
if (a.isNaN && b.isNaN) {
return true;
}
return a == b;
}
if (a is List && b is List) {
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
if (a.length != b.length) {
return false;
}
for (final MapEntry<Object?, Object?> entryA in a.entries) {
bool found = false;
for (final MapEntry<Object?, Object?> entryB in b.entries) {
if (_deepEquals(entryA.key, entryB.key)) {
if (_deepEquals(entryA.value, entryB.value)) {
found = true;
break;
} else {
return false;
}
}
}
if (!found) {
return false;
}
}
return true;
}
return a == b;
}
int _deepHash(Object? value) {
if (value is List) {
return Object.hashAll(value.map(_deepHash));
}
if (value is Map) {
int result = 0;
for (final MapEntry<Object?, Object?> entry in value.entries) {
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
}
return result;
}
if (value is double && value.isNaN) {
// Normalize NaN to a consistent hash.
return 0x7FF8000000000000.hashCode;
}
if (value is double && value == 0.0) {
// Normalize -0.0 to 0.0 so they have the same hash code.
return 0.0.hashCode;
}
return value.hashCode;
}
class ViewIntentPayload {
ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
String? path;
String mimeType;
String? localAssetId;
List<Object?> _toList() {
return <Object?>[path, mimeType, localAssetId];
}
Object encode() {
return _toList();
}
static ViewIntentPayload decode(Object result) {
result as List<Object?>;
return ViewIntentPayload(
path: result[0] as String?,
mimeType: result[1]! as String,
localAssetId: result[2] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! ViewIntentPayload || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(mimeType, other.mimeType) &&
_deepEquals(localAssetId, other.localAssetId);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is ViewIntentPayload) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return ViewIntentPayload.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class ViewIntentHostApi {
/// Constructor for [ViewIntentHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<ViewIntentPayload?> consumeViewIntent() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as ViewIntentPayload?;
}
}
@@ -1,16 +1,25 @@
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 ConsumerWidget {
class MainTimelinePage extends HookConsumerWidget {
const MainTimelinePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
useEffect(() {
unawaited(Future<void>(() => 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()),
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -7,9 +8,11 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
@@ -26,7 +29,11 @@ class UploadActionButton extends ConsumerWidget {
}
final isTimeline = source == ActionSource.timeline;
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
List<LocalAsset>? assets;
var isUploadDialogOpen = false;
var wasUploadCancelled = false;
Future<void>? uploadDialogFuture;
if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
@@ -35,22 +42,44 @@ class UploadActionButton extends ConsumerWidget {
}
ref.read(multiSelectProvider.notifier).reset();
} else {
unawaited(
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => const _UploadProgressDialog(),
),
);
isUploadDialogOpen = true;
uploadDialogFuture =
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _UploadProgressDialog(
onCancel: () {
wasUploadCancelled = true;
},
),
).whenComplete(() {
isUploadDialogOpen = false;
});
unawaited(uploadDialogFuture);
}
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
var success = false;
if (!isTimeline && viewerIntentFilePath != null) {
var hasError = false;
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onError: (fileId, errorMessage) {
hasError = true;
},
);
success = !hasError;
} else {
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
success = result.success;
}
if (!isTimeline && context.mounted) {
if (!isTimeline && context.mounted && isUploadDialogOpen) {
Navigator.of(context, rootNavigator: true).pop();
}
if (context.mounted && !result.success) {
if (context.mounted && !success && !wasUploadCancelled) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
@@ -73,7 +102,9 @@ class UploadActionButton extends ConsumerWidget {
}
class _UploadProgressDialog extends ConsumerWidget {
const _UploadProgressDialog();
final VoidCallback onCancel;
const _UploadProgressDialog({required this.onCancel});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -103,7 +134,8 @@ class _UploadProgressDialog extends ConsumerWidget {
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Navigator.of(context).pop();
onCancel();
Navigator.of(context, rootNavigator: true).pop();
},
labelText: 'cancel'.t(context: context),
),
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
@@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -323,14 +325,18 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent,
required bool isPlayingMotionVideo,
required String? localFilePath,
}) {
final size = context.sizeData;
final imageProvider = localFilePath != null
? FileImage(File(localFilePath))
: getFullImageProvider(asset, size: size);
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView(
key: Key(asset.heroTag),
index: widget.index,
imageProvider: getFullImageProvider(asset, size: size),
imageProvider: imageProvider,
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
gaplessPlayback: true,
@@ -377,12 +383,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag),
asset: asset,
localFilePath: localFilePath,
isCurrent: isCurrent,
image: Image(
image: getFullImageProvider(asset, size: size),
fit: BoxFit.contain,
alignment: Alignment.center,
),
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
@@ -393,6 +396,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final asset = _asset;
if (asset == null) {
@@ -421,6 +425,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_scrollController.snapPosition.snapOffset = _snapOffset;
}
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
return Stack(
children: [
SingleChildScrollView(
@@ -440,6 +446,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
: null,
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
localFilePath: viewIntentFilePath,
),
),
IgnorePointer(
@@ -64,18 +64,7 @@ class AssetViewer extends ConsumerStatefulWidget {
ConsumerState createState() => _AssetViewerState();
static void setAsset(WidgetRef ref, BaseAsset 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);
prepareAssetViewerState(ref.read(assetViewerProvider.notifier), asset);
}
}
@@ -89,6 +78,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
bool _disposeStarted = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
@@ -123,6 +113,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
@override
void dispose() {
_disposeStarted = true;
_pageController.dispose();
_preloader.dispose();
_reloadSubscription?.cancel();
@@ -160,14 +151,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onAssetChanged(int index) async {
if (!mounted) {
return;
}
_currentPage = index;
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
if (asset == null) {
if (!mounted || asset == null) {
return;
}
AssetViewer._setAsset(ref, asset);
ref.read(assetViewerProvider.notifier).setAsset(asset);
_preloader.preload(index, context.sizeData);
_handleCasting();
_stackChildrenKeepAlive?.close();
@@ -203,6 +197,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onEvent(Event event) {
if (!mounted || _disposeStarted) {
return;
}
switch (event) {
case TimelineReloadEvent():
_onTimelineReloadEvent();
@@ -226,13 +223,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
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);
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -19,6 +20,7 @@ import 'package:native_video_player/native_video_player.dart';
class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset;
final String? localFilePath;
final bool isCurrent;
final bool showControls;
final Widget image;
@@ -26,6 +28,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
const NativeVideoViewer({
super.key,
required this.asset,
this.localFilePath,
required this.image,
this.isCurrent = false,
this.showControls = true,
@@ -106,6 +109,19 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
try {
final localFilePath = widget.localFilePath;
if (localFilePath != null) {
final file = File(localFilePath);
if (!await file.exists()) {
throw Exception('No file found for the video');
}
return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
}
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
@@ -10,6 +10,7 @@ class AssetViewerState {
final bool isZoomed;
final BaseAsset? currentAsset;
final int stackIndex;
final bool isViewerTransitionInProgress;
const AssetViewerState({
this.backgroundOpacity = 1.0,
@@ -18,6 +19,7 @@ class AssetViewerState {
this.isZoomed = false,
this.currentAsset,
this.stackIndex = 0,
this.isViewerTransitionInProgress = false,
});
AssetViewerState copyWith({
@@ -27,6 +29,7 @@ class AssetViewerState {
bool? isZoomed,
BaseAsset? currentAsset,
int? stackIndex,
bool? isViewerTransitionInProgress,
}) {
return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
@@ -35,6 +38,7 @@ class AssetViewerState {
isZoomed: isZoomed ?? this.isZoomed,
currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex,
isViewerTransitionInProgress: isViewerTransitionInProgress ?? this.isViewerTransitionInProgress,
);
}
@@ -57,7 +61,8 @@ class AssetViewerState {
other.showingControls == showingControls &&
other.isZoomed == isZoomed &&
other.currentAsset == currentAsset &&
other.stackIndex == stackIndex;
other.stackIndex == stackIndex &&
other.isViewerTransitionInProgress == isViewerTransitionInProgress;
}
@override
@@ -67,7 +72,8 @@ class AssetViewerState {
showingControls.hashCode ^
isZoomed.hashCode ^
currentAsset.hashCode ^
stackIndex.hashCode;
stackIndex.hashCode ^
isViewerTransitionInProgress.hashCode;
}
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@@ -137,10 +143,28 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
}
state = state.copyWith(stackIndex: index);
}
void setViewerTransitionInProgress(bool isInProgress) {
if (isInProgress == state.isViewerTransitionInProgress) {
return;
}
state = state.copyWith(isViewerTransitionInProgress: isInProgress);
}
}
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(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<BaseAsset?>((ref) {
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
final asset = ref.read(assetViewerProvider).currentAsset;
@@ -1,101 +1,101 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.read(foregroundUploadServiceProvider),
ref.read(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState = state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
state = [];
}
Future<void> uploadAll(List<File> files) async {
for (final file in files) {
final fileId = p.hash(file.path).toString();
_updateStatus(fileId, UploadStatus.running);
}
await _foregroundUploadService.uploadShareIntent(
files,
onProgress: (fileId, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress);
},
onSuccess: (fileId) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
},
onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed);
},
);
}
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id)
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
else
attachment,
];
}
void _updateProgress(String fileId, double progress) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
];
}
}
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.read(foregroundUploadServiceProvider),
ref.read(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState = state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
state = [];
}
Future<void> uploadAll(List<File> files) async {
for (final file in files) {
final fileId = p.hash(file.path).toString();
_updateStatus(fileId, UploadStatus.running);
}
await _foregroundUploadService.uploadShareIntent(
files,
onProgress: (fileId, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress);
},
onSuccess: (fileId, _) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
},
onError: (fileId, errorMessage) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed);
},
);
}
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id)
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
else
attachment,
];
}
void _updateProgress(String fileId, double progress) {
final id = int.parse(fileId);
state = [
for (final attachment in state)
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
];
}
}
@@ -31,11 +31,12 @@ class ActionResult {
final int count;
final bool success;
final String? error;
final List<String> remoteAssetIds;
const ActionResult({required this.count, required this.success, this.error});
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
@override
String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
}
class ActionNotifier extends Notifier<void> {
@@ -489,10 +490,14 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
if (assetsToUpload.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
}
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
final remoteAssetIds = <String>[];
// Initialize progress for all assets
for (final asset in assetsToUpload) {
@@ -509,6 +514,7 @@ class ActionNotifier extends Notifier<void> {
progressNotifier.setProgress(localAssetId, progress);
},
onSuccess: (localAssetId, remoteAssetId) {
remoteAssetIds.add(remoteAssetId);
progressNotifier.remove(localAssetId);
},
onError: (localAssetId, errorMessage) {
@@ -516,7 +522,14 @@ class ActionNotifier extends Notifier<void> {
},
),
);
return ActionResult(count: assetsToUpload.length, success: true);
final uploadedCount = remoteAssetIds.length;
final success = uploadedCount == assetsToUpload.length;
return ActionResult(
count: assetsToUpload.length,
success: success,
error: success ? null : 'Uploaded $uploadedCount/${assetsToUpload.length} assets successfully',
remoteAssetIds: remoteAssetIds,
);
} catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack);
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
@@ -41,3 +41,23 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
});
final timelineStatusProvider = StreamProvider.autoDispose.family<TimelineStatus, TimelineService>((
ref,
timelineService,
) async* {
yield timelineService.status;
yield* timelineService.watchStatus();
});
Future<void> waitForTimelineReady(TimelineService timelineService, Duration timeout) {
if (timelineService.isReady) {
return Future.value();
}
return timelineService
.watchStatus()
.firstWhere((status) => status == TimelineStatus.ready)
.timeout(timeout)
.then((_) {});
}
@@ -0,0 +1,31 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ViewIntentFilePathNotifier extends Notifier<String?> {
@override
String? build() => null;
void setPath(String path) {
if (state == path) {
return;
}
state = path;
}
void clear() {
if (state == null) {
return;
}
state = null;
}
void clearIfMatch(String path) {
if (state != path) {
return;
}
state = null;
}
}
final viewIntentFilePathProvider = NotifierProvider<ViewIntentFilePathNotifier, String?>(
ViewIntentFilePathNotifier.new,
);
@@ -0,0 +1,23 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart';
abstract class ViewIntentHandler {
void init();
Future<void> onAppResumed();
Future<void> flushDeferredViewIntent();
Future<void> handle(ViewIntentPayload attachment);
}
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {
if (Platform.isAndroid) {
return AndroidViewIntentHandler(ref);
}
return const StubViewIntentHandler();
});
@@ -0,0 +1,118 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_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';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:logging/logging.dart';
class AndroidViewIntentHandler implements ViewIntentHandler {
final Ref _ref;
final ViewIntentService _viewIntentService;
final ViewIntentAssetResolver _viewIntentAssetResolver;
final AppRouter _router;
static final Logger _logger = Logger('ViewIntentHandler');
AndroidViewIntentHandler(Ref ref)
: _ref = ref,
_viewIntentService = ref.read(viewIntentServiceProvider),
_viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
_router = ref.watch(appRouterProvider);
@override
void init() {
// Covers cold start from a view intent before the first lifecycle "resumed".
unawaited(onAppResumed());
}
@override
Future<void> onAppResumed() => _checkForViewIntent();
@override
Future<void> flushDeferredViewIntent() => _flushPending();
Future<void> _checkForViewIntent() async {
final attachment = await _viewIntentService.consumeViewIntent();
if (attachment != null) {
await handle(attachment);
return;
}
if (_ref.read(viewIntentPendingProvider) == null) {
await _viewIntentService.cleanupStaleTempFiles();
}
}
Future<void> _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}');
if (pendingAttachment != null) {
await handle(pendingAttachment);
}
}
@override
Future<void> handle(ViewIntentPayload attachment) async {
_logger.info(
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
);
if (!_ref.read(authProvider).isAuthenticated) {
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
return;
}
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
await _openAssetViewer(
resolvedAsset.asset,
resolvedAsset.timelineService,
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
);
}
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
final notifier = _ref.read(assetViewerProvider.notifier);
notifier.setViewerTransitionInProgress(true);
try {
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());
}
// Mirror the home-screen widget pattern: replace the route stack so
// the viewer sits directly on top of the main timeline. Back-press
// from the viewer lands the user on the timeline rather than on
// whatever route happened to be current (e.g. splash, login).
await _router.replaceAll([
const TabShellRoute(),
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
]);
} finally {
notifier.setViewerTransitionInProgress(false);
}
}
}
@@ -0,0 +1,18 @@
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
class StubViewIntentHandler implements ViewIntentHandler {
const StubViewIntentHandler();
@override
void init() {}
@override
Future<void> onAppResumed() async {}
@override
Future<void> flushDeferredViewIntent() async {}
@override
Future<void> handle(ViewIntentPayload attachment) async {}
}
@@ -0,0 +1,59 @@
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, bool>(
ViewIntentMainTimelineReadyNotifier.new,
);
class ViewIntentMainTimelineReadyNotifier extends Notifier<bool> {
Completer<void>? _readyCompleter;
bool _hasSeenMainTimeline = false;
bool _hasTimelineUsers = false;
bool _isTimelineReady = false;
@override
bool build() {
_readyCompleter ??= Completer<void>();
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<void> 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<void>();
}
}
}
@@ -0,0 +1,39 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
final viewIntentNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
final viewIntentPendingProvider = NotifierProvider<ViewIntentPendingNotifier, ViewIntentPayload?>(
ViewIntentPendingNotifier.new,
);
class ViewIntentPendingNotifier extends Notifier<ViewIntentPayload?> {
static const _ttl = Duration(minutes: 10);
DateTime? _deferredAt;
@override
ViewIntentPayload? build() => null;
void defer(ViewIntentPayload attachment) {
_deferredAt = ref.read(viewIntentNowProvider)();
state = attachment;
}
ViewIntentPayload? takeIfFresh() {
final attachment = state;
final deferredAt = _deferredAt;
state = null;
_deferredAt = null;
if (attachment == null) {
return null;
}
if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) {
return null;
}
return attachment;
}
}
@@ -151,7 +151,7 @@ class ForegroundUploadService {
List<File> files, {
Completer<void>? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId)? onSuccess,
void Function(String fileId, String remoteAssetId)? onSuccess,
void Function(String fileId, String errorMessage)? onError,
}) async {
if (files.isEmpty) {
@@ -171,7 +171,7 @@ class ForegroundUploadService {
);
if (result.isSuccess) {
onSuccess?.call(fileId);
onSuccess?.call(fileId, result.remoteAssetId!);
} else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!);
}
@@ -0,0 +1,91 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
class ViewIntentService {
final ViewIntentHostApi _viewIntentHostApi;
final Future<Directory> Function() _temporaryDirectory;
String? _managedTempFilePath;
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
Future<ViewIntentPayload?> consumeViewIntent() async {
try {
return await _viewIntentHostApi.consumeViewIntent();
} catch (_) {
// Ignore errors - view intent might not be present
return null;
}
}
Future<void> setManagedTempFilePath(String path) async {
final previous = _managedTempFilePath;
if (previous == path) {
return;
}
_managedTempFilePath = path;
if (previous != null) {
await cleanupTempFile(previous);
}
}
Future<void> cleanupManagedTempFile() async {
final path = _managedTempFilePath;
_managedTempFilePath = null;
if (path != null) {
await cleanupTempFile(path);
}
}
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
if (_managedTempFilePath == path) {
_managedTempFilePath = null;
}
await cleanupTempFile(path);
}
Future<void> cleanupTempFile(String path) async {
if (!_isManagedTempFile(path)) {
return;
}
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
Future<void> cleanupStaleTempFiles() async {
try {
final tempDirectory = await _temporaryDirectory();
await for (final entity in tempDirectory.list()) {
if (entity is! File) {
continue;
}
final path = entity.path;
if (!_isManagedTempFile(path) || path == _managedTempFilePath) {
continue;
}
await entity.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
bool _isManagedTempFile(String path) {
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
}
}
@@ -0,0 +1,90 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class ViewIntentResolvedAsset {
final BaseAsset asset;
final TimelineService timelineService;
/// Path to the materialized temp file backing this asset, if any. Set only
/// for the transient deep-link case (no DB-backed local asset). The upload
/// flow reads this to know which file to upload.
final String? viewIntentFilePath;
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
}
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
(ref) => ViewIntentAssetResolver(
localAssetRepository: ref.read(localAssetRepository),
timelineFactory: ref.read(timelineFactoryProvider),
),
);
/// Resolves an incoming ACTION_VIEW intent into the data the asset viewer
/// needs: a [BaseAsset] and a [TimelineService] containing it.
///
/// Always wraps the resolved asset in a 1-element [TimelineOrigin.deepLink]
/// timeline — mirroring how the app's home-screen widgets open a single
/// asset. We don't try to map the asset to its position in the user's main
/// timeline because that would require ROW_NUMBER queries over the full
/// merged timeline (slow at scale) and complex "wait until the main timeline
/// service is ready at that index" coordination. Back-navigation from the
/// viewer lands on the main timeline because the handler pushes the viewer
/// on top of [TabShellRoute].
class ViewIntentAssetResolver {
final DriftLocalAssetRepository _localAssetRepository;
final TimelineFactory _timelineFactory;
static final Logger _logger = Logger('ViewIntentAssetResolver');
const ViewIntentAssetResolver({
required DriftLocalAssetRepository localAssetRepository,
required TimelineFactory timelineFactory,
}) : _localAssetRepository = localAssetRepository,
_timelineFactory = timelineFactory;
Future<ViewIntentResolvedAsset> 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.');
}
// Prefer the DB-backed local asset when we have one — it carries richer
// metadata than the transient model we'd otherwise synthesise.
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
final asset = localAsset ?? _toTransientAsset(attachment);
return ViewIntentResolvedAsset(
asset: asset,
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
// viewIntentFilePath is only meaningful for the transient case — the
// DB-backed local asset carries its own path/URI for the upload flow.
viewIntentFilePath: localAsset == null ? path : null,
);
}
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,
type: attachment.isVideo ? AssetType.video : AssetType.image,
createdAt: now,
updatedAt: now,
isEdited: false,
playbackStyle: attachment.playbackStyle,
);
}
}
@@ -21,6 +21,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -182,9 +183,11 @@ class LoginForm extends HookConsumerWidget {
Future<void> handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await viewIntentHandler.flushDeferredViewIntent();
await backgroundManager.hashAssets();
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
@@ -259,7 +262,7 @@ class LoginForm extends HookConsumerWidget {
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
} catch (error) {
@@ -346,7 +349,7 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
} catch (error, stack) {
+2 -1
View File
@@ -29,7 +29,8 @@ run = [
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
"dart run pigeon --input pigeon/connectivity_api.dart",
"dart run pigeon --input pigeon/network_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
"dart run pigeon --input pigeon/view_intent_api.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
]
[tasks."codegen:translation"]
+24
View File
@@ -0,0 +1,24 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/view_intent_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.viewintent'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
class ViewIntentPayload {
final String? path;
final String mimeType;
final String? localAssetId;
const ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
}
@HostApi()
abstract class ViewIntentHostApi {
@async
ViewIntentPayload? consumeViewIntent();
}
@@ -0,0 +1,276 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/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';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:mocktail/mocktail.dart';
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
class MockAppRouter extends Mock implements AppRouter {}
class MockAuthService extends Mock implements AuthService {}
class MockApiService extends Mock implements ApiService {}
class MockUserService extends Mock implements UserService {}
class MockSecureStorageService extends Mock implements SecureStorageService {}
class MockWidgetService extends Mock implements WidgetService {}
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
class FakeTimelineService extends Fake implements TimelineService {}
class TestViewIntentService extends ViewIntentService {
ViewIntentPayload? consumedAttachment;
int cleanupStaleTempFilesCalls = 0;
int cleanupManagedTempFileCalls = 0;
final List<String> managedTempPaths = [];
TestViewIntentService() : super(MockViewIntentHostApi());
@override
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
@override
Future<void> cleanupStaleTempFiles() async {
cleanupStaleTempFilesCalls++;
}
@override
Future<void> cleanupManagedTempFile() async {
cleanupManagedTempFileCalls++;
}
@override
Future<void> setManagedTempFilePath(String path) async {
managedTempPaths.add(path);
}
}
class TestAuthNotifier extends AuthNotifier {
TestAuthNotifier(Ref ref, AuthState initial)
: super(
MockAuthService(),
MockApiService(),
MockUserService(),
MockSecureStorageService(),
MockWidgetService(),
ref,
) {
state = initial;
}
void setAuthenticated(bool isAuthenticated) {
state = state.copyWith(isAuthenticated: isAuthenticated);
}
}
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late TestViewIntentService viewIntentService;
late MockViewIntentAssetResolver resolver;
late MockAppRouter router;
late TestAuthNotifier authNotifier;
late ProviderContainer container;
late AndroidViewIntentHandler handler;
late ViewIntentPayload payload;
late LocalAsset deepLinkAsset;
late TimelineService deepLinkTimelineService;
setUpAll(() {
registerFallbackValue(FakePageRouteInfo());
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
registerFallbackValue(FakeTimelineService());
registerFallbackValue(
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
);
});
setUp(() async {
viewIntentService = TestViewIntentService();
resolver = MockViewIntentAssetResolver();
router = MockAppRouter();
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
deepLinkAsset = _localAsset(id: 'local-1');
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
when(() => router.replaceAll(any())).thenAnswer((_) async {});
container = ProviderContainer(
overrides: [
viewIntentServiceProvider.overrideWithValue(viewIntentService),
viewIntentAssetResolverProvider.overrideWithValue(resolver),
appRouterProvider.overrideWithValue(router),
// viewIntentMainTimelineReadyProvider reads both of these to compute
// its ready state — without them wait() never resolves.
timelineServiceProvider.overrideWithValue(deepLinkTimelineService),
timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])),
authProvider.overrideWith((ref) {
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
return authNotifier;
}),
],
);
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
await container.read(timelineUsersProvider.future);
handler = container.read(_handlerProvider);
addTearDown(() async {
await deepLinkTimelineService.dispose();
container.dispose();
});
});
test('handle defers unauthenticated attachment', () async {
authNotifier.setAuthenticated(false);
await handler.handle(payload);
expect(container.read(viewIntentPendingProvider), payload);
verifyNever(() => resolver.resolve(any()));
});
testWidgets('flushDeferredViewIntent waits for main timeline readiness before flushing pending attachment', (
tester,
) async {
authNotifier.setAuthenticated(false);
container.read(viewIntentPendingProvider.notifier).defer(payload);
authNotifier.setAuthenticated(true);
when(() => resolver.resolve(payload)).thenAnswer((_) async {
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
});
unawaited(handler.flushDeferredViewIntent());
await tester.pump();
expect(container.read(viewIntentPendingProvider), payload);
verifyNever(() => resolver.resolve(any()));
container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce();
await tester.pump();
await tester.pump();
await tester.idle();
expect(container.read(viewIntentPendingProvider), isNull);
verify(() => resolver.resolve(payload)).called(1);
});
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
await handler.flushDeferredViewIntent();
verifyNever(() => resolver.resolve(any()));
});
test('onAppResumed cleans stale temp files when no attachment is present', () async {
viewIntentService.consumedAttachment = null;
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
verifyNever(() => resolver.resolve(any()));
});
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
viewIntentService.consumedAttachment = null;
container.read(viewIntentPendingProvider.notifier).defer(payload);
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
verifyNever(() => resolver.resolve(any()));
});
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
viewIntentService.consumedAttachment = payload;
when(() => resolver.resolve(payload)).thenAnswer(
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
);
unawaited(handler.onAppResumed());
await tester.pump();
await tester.pump();
await tester.pump();
await tester.idle();
verify(() => resolver.resolve(payload)).called(1);
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
// main timeline — mirrors the home-screen widget navigation pattern.
final captured = verify(() => router.replaceAll(captureAny())).captured;
expect(captured, hasLength(1));
final routes = captured.single as List<PageRouteInfo<dynamic>>;
expect(routes, hasLength(2));
expect(routes[0].routeName, TabShellRoute.name);
expect(routes[1].routeName, AssetViewerRoute.name);
});
}
AuthState _authState({required bool isAuthenticated}) {
return AuthState(
deviceId: 'device-1',
userId: 'user-1',
userEmail: 'user@example.com',
isAuthenticated: isAuthenticated,
name: 'User',
isAdmin: false,
profileImagePath: '',
);
}
LocalAsset _localAsset({required String id}) {
return LocalAsset(
id: id,
name: '$id.jpg',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
return TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: origin,
));
}
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
final timelineService = _timelineServiceFromAssets(assets, origin);
if (!timelineService.isReady) {
await timelineService.watchStatus().firstWhere((status) => status == TimelineStatus.ready);
}
return timelineService;
}
@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
void main() {
late DateTime now;
late ProviderContainer container;
final attachment = ViewIntentPayload(
path: '/tmp/file.jpg',
mimeType: 'image/jpeg',
localAssetId: '42',
);
setUp(() {
now = DateTime(2026, 4, 17, 12);
container = ProviderContainer(
overrides: [viewIntentNowProvider.overrideWithValue(() => now)],
);
addTearDown(container.dispose);
});
test('defer stores pending attachment', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
expect(container.read(viewIntentPendingProvider), attachment);
});
test('takeIfFresh returns pending attachment once', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
final first = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
final second = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(first, attachment);
expect(second, isNull);
});
test('takeIfFresh drops expired attachment', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
now = now.add(const Duration(minutes: 11));
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(result, isNull);
expect(container.read(viewIntentPendingProvider), isNull);
});
test('newer deferred attachment replaces older one', () {
final newerAttachment = ViewIntentPayload(
path: '/tmp/file-2.jpg',
mimeType: 'image/jpeg',
localAssetId: '43',
);
container.read(viewIntentPendingProvider.notifier).defer(attachment);
container.read(viewIntentPendingProvider.notifier).defer(newerAttachment);
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(result, newerAttachment);
});
}
@@ -0,0 +1,123 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
class MockTimelineFactory extends Mock implements TimelineFactory {}
void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockTimelineFactory timelineFactory;
late List<TimelineService> createdTimelineServices;
late ProviderContainer container;
setUp(() {
mockLocalAssetRepository = MockDriftLocalAssetRepository();
timelineFactory = MockTimelineFactory();
createdTimelineServices = [];
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink);
createdTimelineServices.add(timelineService);
return timelineService;
});
container = ProviderContainer(
overrides: [
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
],
);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {
await timelineService.dispose();
}
container.dispose();
});
});
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
expect(result.asset, equals(localAsset));
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
});
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
expect(result.asset, isA<LocalAsset>());
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
});
test('returns transient asset for path-only attachment', () async {
final result = await _resolve(
container,
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
);
expect(result.asset, isA<LocalAsset>());
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
final asset = result.asset as LocalAsset;
expect(asset.localId, startsWith('-'));
expect(asset.name, 'incoming.webp');
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
});
test('throws when neither localAssetId nor path is provided', () async {
await expectLater(
_resolve(container, _payload(localAssetId: null, path: null)),
throwsA(isA<StateError>()),
);
});
}
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
return container.read(viewIntentAssetResolverProvider).resolve(payload);
}
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
}
LocalAsset _localAsset({required String id, String? checksum}) {
return LocalAsset(
id: id,
name: '$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
return TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: origin,
));
}
@@ -0,0 +1,96 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:mocktail/mocktail.dart';
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
void main() {
late MockViewIntentHostApi hostApi;
late ViewIntentService service;
late Directory tempRoot;
late Directory cacheDir;
final attachment = ViewIntentPayload(
path: '/tmp/file.jpg',
mimeType: 'image/jpeg',
localAssetId: '42',
);
setUp(() {
hostApi = MockViewIntentHostApi();
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
});
tearDown(() async {
clearInteractions(hostApi);
if (await tempRoot.exists()) {
await tempRoot.delete(recursive: true);
}
});
test('consumeViewIntent returns null when no attachment', () async {
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
final result = await service.consumeViewIntent();
expect(result, isNull);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('consumeViewIntent returns attachment when present', () async {
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
final result = await service.consumeViewIntent();
expect(result, attachment);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('consumeViewIntent swallows host api errors', () async {
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
final result = await service.consumeViewIntent();
expect(result, isNull);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('setManagedTempFilePath cleans previous managed temp file', () async {
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
await service.setManagedTempFilePath(firstFile.path);
await service.setManagedTempFilePath(secondFile.path);
expect(await firstFile.exists(), isFalse);
expect(await secondFile.exists(), isTrue);
await service.cleanupManagedTempFile();
expect(await secondFile.exists(), isFalse);
});
test('cleanupTempFile ignores non-managed paths', () async {
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
await service.cleanupTempFile(nonManagedFile.path);
expect(await nonManagedFile.exists(), isTrue);
});
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
await service.cleanupStaleTempFiles();
expect(await firstFile.exists(), isFalse);
expect(await secondFile.exists(), isFalse);
expect(await unrelatedFile.exists(), isTrue);
});
}