feat(mobile): Android. Immich as a gallery / image viewer app (#26109)
* 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 * 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 * fix(mobile): proper handling is user authenticated * feat(mobile): open ACTION_VIEW fallback in AssetViewer drop ExternalMediaViewer route * feat(mobile): add logger * test(mobile): add unit tests for view intent pending/flush flow * fix(mobile): fix format * fix(mobile): remove redundant iOS code update code related to LocalAsset model and asset viewer * 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 * refactor(mobile): extract MediaStore utils and resolve view intents via merged assets * refactor(mobile): move deferred view intents into providers, split view-intent providers, and clean up ACTION_VIEW handling * refactor(mobile): resolve merge conflicts use NativeSyncApi for hash files instead method from removed BackgroundServicePlugin.kt * style(mobile): format files * style(mobile): format files #2 * refactor(mobile): lazily materialize view-intent files and clean up temp-file handling * fix(mobile): flush pending view intents after login navigation * refactor(mobile): split view intent handler by platform and trigger it from app events * refactor(mobile): move view intent handling behind platform-specific factories * refactor(mobile): simplify code * 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. * refactor(mobile): use remote asset ids for view intent handoff and simplify resolver * refactor(mobile): resolve merge conflicts * style(mobile): reformat code * style(mobile): reformat code #2 * fix(mobile): stabilize Android view intent asset resolution and fallback viewer * refactor(mobile): share AssetViewer pre-navigation state preparation * fix(mobile): wait for main timeline before deferred view intent handoff * refactor(mobile): decouple view intent asset resolver from providers * fix(mobile): avoid double pop when canceling upload dialog * fix(mobile): resolve view intent MIME type with fallbacks * docs(mobile): clarify view intent fallback asset TODO * fix(mobile): resolve merge conflicts * cleanup * lint --------- Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com> Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>pull/28617/merge
parent
e4352a7817
commit
9d4a6614b1
|
|
@ -89,6 +89,20 @@
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent-filter>
|
</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 -->
|
<!-- immich:// URL scheme handling -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ext.SdkExtensions
|
import android.os.ext.SdkExtensions
|
||||||
import app.alextran.immich.background.BackgroundEngineLock
|
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.NativeSyncApi
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||||
|
import app.alextran.immich.viewintent.ViewIntentPlugin
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
|
|
@ -31,6 +33,11 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
registerPlugins(this, flutterEngine)
|
registerPlugins(this, flutterEngine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
HttpClientManager.initialize(ctx)
|
HttpClientManager.initialize(ctx)
|
||||||
|
|
@ -55,6 +62,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||||
|
|
||||||
|
flutterEngine.plugins.add(ViewIntentPlugin())
|
||||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||||
flutterEngine.plugins.add(permissionApiImpl)
|
flutterEngine.plugins.add(permissionApiImpl)
|
||||||
|
|
|
||||||
292
mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt
generated
Normal file
292
mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt
generated
Normal file
|
|
@ -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,201 @@
|
||||||
|
package app.alextran.immich.viewintent
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import 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 unconsumedIntent: 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
|
||||||
|
unconsumedIntent = 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 {
|
||||||
|
unconsumedIntent = intent
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
|
||||||
|
val context = context ?: run {
|
||||||
|
callback(Result.success(null))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = unconsumedIntent ?: 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) {
|
||||||
|
unconsumedIntent = Intent(currentIntent).apply {
|
||||||
|
action = null
|
||||||
|
data = null
|
||||||
|
type = null
|
||||||
|
}
|
||||||
|
activity?.intent = unconsumedIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
|
||||||
|
return tryExtractDocumentLocalAssetId(context, uri)
|
||||||
|
?: tryParseContentUriId(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
|
||||||
|
docId.substringAfter(':', docId).toLongOrNull()?.toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryParseContentUriId(uri: Uri): String? {
|
||||||
|
val id = uri.lastPathSegment?.toLongOrNull() ?: return null
|
||||||
|
return if (id >= 0) id.toString() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
|
||||||
|
return try {
|
||||||
|
val 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 -> return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
context.contentResolver
|
||||||
|
.query(
|
||||||
|
tableUri,
|
||||||
|
arrayOf(MediaStore.MediaColumns._ID),
|
||||||
|
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
|
||||||
|
arrayOf(displayName, size.toString()),
|
||||||
|
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return null
|
||||||
|
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
||||||
|
if (idIndex < 0) return null
|
||||||
|
cursor.getLong(idIndex).toString()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/platform/background_worker_lock_api.g.dart';
|
||||||
import 'package:immich_mobile/providers/app_life_cycle.provider.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/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/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.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:
|
case AppLifecycleState.resumed:
|
||||||
dPrint(() => "[APP STATE] resumed");
|
dPrint(() => "[APP STATE] resumed");
|
||||||
ref.read(appStateProvider.notifier).handleAppResume();
|
ref.read(appStateProvider.notifier).handleAppResume();
|
||||||
|
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
||||||
break;
|
break;
|
||||||
case AppLifecycleState.inactive:
|
case AppLifecycleState.inactive:
|
||||||
dPrint(() => "[APP STATE] 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();
|
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/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.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/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/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/theme/color_scheme.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 wsProvider = ref.read(websocketProvider.notifier);
|
||||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||||
|
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||||
|
|
@ -328,6 +330,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await viewIntentHandler.flushDeferredViewIntent();
|
||||||
|
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
backgroundManager.hashAssets().then((_) {
|
backgroundManager.hashAssets().then((_) {
|
||||||
|
|
|
||||||
|
|
@ -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,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
|
@ -10,6 +11,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
|
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
|
|
@ -26,7 +30,11 @@ class UploadActionButton extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
final isTimeline = source == ActionSource.timeline;
|
final isTimeline = source == ActionSource.timeline;
|
||||||
|
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
|
||||||
List<LocalAsset>? assets;
|
List<LocalAsset>? assets;
|
||||||
|
var isUploadDialogOpen = false;
|
||||||
|
var wasUploadCancelled = false;
|
||||||
|
Future<void>? uploadDialogFuture;
|
||||||
|
|
||||||
if (source == ActionSource.timeline) {
|
if (source == ActionSource.timeline) {
|
||||||
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
||||||
|
|
@ -35,22 +43,50 @@ class UploadActionButton extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
} else {
|
} else {
|
||||||
unawaited(
|
isUploadDialogOpen = true;
|
||||||
showDialog(
|
uploadDialogFuture =
|
||||||
context: context,
|
showDialog<void>(
|
||||||
barrierDismissible: false,
|
context: context,
|
||||||
builder: (dialogContext) => const _UploadProgressDialog(),
|
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) {
|
||||||
|
final viewIntentService = ref.read(viewIntentServiceProvider);
|
||||||
|
viewIntentService.markUploadActive(viewerIntentFilePath);
|
||||||
|
var hasError = false;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(foregroundUploadServiceProvider)
|
||||||
|
.uploadShareIntent(
|
||||||
|
[File(viewerIntentFilePath)],
|
||||||
|
onError: (_, _) {
|
||||||
|
hasError = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await viewIntentService.markUploadInactive(viewerIntentFilePath);
|
||||||
|
}
|
||||||
|
success = !hasError;
|
||||||
|
} else {
|
||||||
|
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||||
|
success = result.success;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isTimeline && context.mounted) {
|
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
||||||
Navigator.of(context, rootNavigator: true).pop();
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted && !result.success) {
|
if (context.mounted && !success && !wasUploadCancelled) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||||
|
|
@ -73,7 +109,9 @@ class UploadActionButton extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UploadProgressDialog extends ConsumerWidget {
|
class _UploadProgressDialog extends ConsumerWidget {
|
||||||
const _UploadProgressDialog();
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
|
const _UploadProgressDialog({required this.onCancel});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -103,7 +141,8 @@ class _UploadProgressDialog extends ConsumerWidget {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(manualUploadCancelTokenProvider)?.complete();
|
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||||
Navigator.of(context).pop();
|
onCancel();
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
},
|
},
|
||||||
labelText: 'cancel'.t(context: context),
|
labelText: 'cancel'.t(context: context),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,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/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.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/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
|
|
||||||
|
|
@ -323,14 +324,16 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
required PhotoViewHeroAttributes? heroAttributes,
|
required PhotoViewHeroAttributes? heroAttributes,
|
||||||
required bool isCurrent,
|
required bool isCurrent,
|
||||||
required bool isPlayingMotionVideo,
|
required bool isPlayingMotionVideo,
|
||||||
|
required String? localFilePath,
|
||||||
}) {
|
}) {
|
||||||
final size = context.sizeData;
|
final size = context.sizeData;
|
||||||
|
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
|
||||||
|
|
||||||
if (asset.isImage && !isPlayingMotionVideo) {
|
if (asset.isImage && !isPlayingMotionVideo) {
|
||||||
return PhotoView(
|
return PhotoView(
|
||||||
key: Key(asset.heroTag),
|
key: Key(asset.heroTag),
|
||||||
index: widget.index,
|
index: widget.index,
|
||||||
imageProvider: getFullImageProvider(asset, size: size),
|
imageProvider: imageProvider,
|
||||||
heroAttributes: heroAttributes,
|
heroAttributes: heroAttributes,
|
||||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
|
|
@ -377,12 +380,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
child: NativeVideoViewer(
|
child: NativeVideoViewer(
|
||||||
key: _NativeVideoViewerKey(asset.heroTag),
|
key: _NativeVideoViewerKey(asset.heroTag),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
localFilePath: localFilePath,
|
||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
image: Image(
|
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||||
image: getFullImageProvider(asset, size: size),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -393,6 +393,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||||
|
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||||
|
|
||||||
final asset = _asset;
|
final asset = _asset;
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
|
|
@ -421,6 +422,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
_scrollController.snapPosition.snapOffset = _snapOffset;
|
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
|
|
@ -440,6 +443,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||||
: null,
|
: null,
|
||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||||
|
localFilePath: viewIntentFilePath,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IgnorePointer(
|
IgnorePointer(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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 {
|
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
|
final String? localFilePath;
|
||||||
final bool isCurrent;
|
final bool isCurrent;
|
||||||
final bool showControls;
|
final bool showControls;
|
||||||
final Widget image;
|
final Widget image;
|
||||||
|
|
@ -26,6 +28,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||||
const NativeVideoViewer({
|
const NativeVideoViewer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
this.localFilePath,
|
||||||
required this.image,
|
required this.image,
|
||||||
this.isCurrent = false,
|
this.isCurrent = false,
|
||||||
this.showControls = true,
|
this.showControls = true,
|
||||||
|
|
@ -106,6 +109,19 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
final file = await StorageRepository().getFileForAsset(id);
|
final file = await StorageRepository().getFileForAsset(id);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
import 'package:async/async.dart';
|
||||||
|
|
@ -146,10 +147,17 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
|
ImageProvider getFullImageProvider(
|
||||||
|
BaseAsset asset, {
|
||||||
|
Size size = const Size(1080, 1920),
|
||||||
|
bool edited = true,
|
||||||
|
String? localFilePath,
|
||||||
|
}) {
|
||||||
// Create new provider and cache it
|
// Create new provider and cache it
|
||||||
final ImageProvider provider;
|
final ImageProvider provider;
|
||||||
if (_shouldUseLocalAsset(asset)) {
|
if (localFilePath != null) {
|
||||||
|
provider = FileImage(File(localFilePath));
|
||||||
|
} else if (_shouldUseLocalAsset(asset)) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,101 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||||
import 'package:immich_mobile/routing/router.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:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||||
((ref) => ShareIntentUploadStateNotifier(
|
((ref) => ShareIntentUploadStateNotifier(
|
||||||
ref.watch(appRouterProvider),
|
ref.watch(appRouterProvider),
|
||||||
ref.read(foregroundUploadServiceProvider),
|
ref.read(foregroundUploadServiceProvider),
|
||||||
ref.read(shareIntentServiceProvider),
|
ref.read(shareIntentServiceProvider),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||||
final AppRouter router;
|
final AppRouter router;
|
||||||
final ForegroundUploadService _foregroundUploadService;
|
final ForegroundUploadService _foregroundUploadService;
|
||||||
final ShareIntentService _shareIntentService;
|
final ShareIntentService _shareIntentService;
|
||||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||||
|
|
||||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||||
|
|
||||||
void init() {
|
void init() {
|
||||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||||
_shareIntentService.init();
|
_shareIntentService.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSharedMedia(List<ShareIntentAttachment> attachments) {
|
void onSharedMedia(List<ShareIntentAttachment> attachments) {
|
||||||
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
||||||
clearAttachments();
|
clearAttachments();
|
||||||
addAttachments(attachments);
|
addAttachments(attachments);
|
||||||
router.push(ShareIntentRoute(attachments: attachments));
|
router.push(ShareIntentRoute(attachments: attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
void addAttachments(List<ShareIntentAttachment> attachments) {
|
void addAttachments(List<ShareIntentAttachment> attachments) {
|
||||||
if (attachments.isEmpty) {
|
if (attachments.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = [...state, ...attachments];
|
state = [...state, ...attachments];
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeAttachment(ShareIntentAttachment attachment) {
|
void removeAttachment(ShareIntentAttachment attachment) {
|
||||||
final updatedState = state.where((element) => element != attachment).toList();
|
final updatedState = state.where((element) => element != attachment).toList();
|
||||||
if (updatedState.length != state.length) {
|
if (updatedState.length != state.length) {
|
||||||
state = updatedState;
|
state = updatedState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAttachments() {
|
void clearAttachments() {
|
||||||
if (state.isEmpty) {
|
if (state.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = [];
|
state = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> uploadAll(List<File> files) async {
|
Future<void> uploadAll(List<File> files) async {
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
final fileId = p.hash(file.path).toString();
|
final fileId = p.hash(file.path).toString();
|
||||||
_updateStatus(fileId, UploadStatus.running);
|
_updateStatus(fileId, UploadStatus.running);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _foregroundUploadService.uploadShareIntent(
|
await _foregroundUploadService.uploadShareIntent(
|
||||||
files,
|
files,
|
||||||
onProgress: (fileId, bytes, totalBytes) {
|
onProgress: (fileId, bytes, totalBytes) {
|
||||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||||
_updateProgress(fileId, progress);
|
_updateProgress(fileId, progress);
|
||||||
},
|
},
|
||||||
onSuccess: (fileId) {
|
onSuccess: (fileId, _) {
|
||||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||||
},
|
},
|
||||||
onError: (fileId, errorMessage) {
|
onError: (fileId, errorMessage) {
|
||||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||||
_updateStatus(fileId, UploadStatus.failed);
|
_updateStatus(fileId, UploadStatus.failed);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
||||||
final id = int.parse(fileId);
|
final id = int.parse(fileId);
|
||||||
state = [
|
state = [
|
||||||
for (final attachment in state)
|
for (final attachment in state)
|
||||||
if (attachment.id == id)
|
if (attachment.id == id)
|
||||||
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
||||||
else
|
else
|
||||||
attachment,
|
attachment,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateProgress(String fileId, double progress) {
|
void _updateProgress(String fileId, double progress) {
|
||||||
final id = int.parse(fileId);
|
final id = int.parse(fileId);
|
||||||
state = [
|
state = [
|
||||||
for (final attachment in state)
|
for (final attachment in state)
|
||||||
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,12 @@ class ActionResult {
|
||||||
final int count;
|
final int count;
|
||||||
final bool success;
|
final bool success;
|
||||||
final String? error;
|
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
|
@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> {
|
class ActionNotifier extends Notifier<void> {
|
||||||
|
|
@ -554,10 +555,14 @@ class ActionNotifier extends Notifier<void> {
|
||||||
final uploadedAssetIds = <String>{};
|
final uploadedAssetIds = <String>{};
|
||||||
final failedAssetIds = <String>{};
|
final failedAssetIds = <String>{};
|
||||||
final postUploadTasks = <Future<void>>[];
|
final postUploadTasks = <Future<void>>[];
|
||||||
|
if (assetsToUpload.isEmpty) {
|
||||||
|
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
|
||||||
|
}
|
||||||
|
|
||||||
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
||||||
final cancelToken = Completer<void>();
|
final cancelToken = Completer<void>();
|
||||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||||
|
final remoteAssetIds = <String>[];
|
||||||
|
|
||||||
// Initialize progress for all assets
|
// Initialize progress for all assets
|
||||||
for (final asset in assetsToUpload) {
|
for (final asset in assetsToUpload) {
|
||||||
|
|
@ -574,6 +579,7 @@ class ActionNotifier extends Notifier<void> {
|
||||||
progressNotifier.setProgress(localAssetId, progress);
|
progressNotifier.setProgress(localAssetId, progress);
|
||||||
},
|
},
|
||||||
onSuccess: (localAssetId, remoteAssetId) {
|
onSuccess: (localAssetId, remoteAssetId) {
|
||||||
|
remoteAssetIds.add(remoteAssetId);
|
||||||
progressNotifier.remove(localAssetId);
|
progressNotifier.remove(localAssetId);
|
||||||
uploadedAssetIds.add(localAssetId);
|
uploadedAssetIds.add(localAssetId);
|
||||||
final asset = assetById[localAssetId];
|
final asset = assetById[localAssetId];
|
||||||
|
|
|
||||||
|
|
@ -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,103 @@
|
||||||
|
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_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 {
|
||||||
|
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.reset();
|
||||||
|
if (asset.isVideo) {
|
||||||
|
notifier.setControls(false);
|
||||||
|
}
|
||||||
|
notifier.setAsset(asset);
|
||||||
|
|
||||||
|
if (viewIntentFilePath != null) {
|
||||||
|
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
|
||||||
|
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
|
||||||
|
} else {
|
||||||
|
_ref.read(viewIntentFilePathProvider.notifier).clear();
|
||||||
|
unawaited(_viewIntentService.cleanupManagedTempFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
await _router.replaceAll([
|
||||||
|
const TabShellRoute(),
|
||||||
|
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,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, {
|
List<File> files, {
|
||||||
Completer<void>? cancelToken,
|
Completer<void>? cancelToken,
|
||||||
void Function(String fileId, int bytes, int totalBytes)? onProgress,
|
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,
|
void Function(String fileId, String errorMessage)? onError,
|
||||||
}) async {
|
}) async {
|
||||||
if (files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
|
|
@ -171,7 +171,7 @@ class ForegroundUploadService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
onSuccess?.call(fileId);
|
onSuccess?.call(fileId, result.remoteAssetId!);
|
||||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||||
onError?.call(fileId, result.errorMessage!);
|
onError?.call(fileId, result.errorMessage!);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
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;
|
||||||
|
final Set<String> _activeUploadPaths = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (_activeUploadPaths.contains(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 || _activeUploadPaths.contains(path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await entity.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort cleanup only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void markUploadActive(String path) {
|
||||||
|
_activeUploadPaths.add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markUploadInactive(String path) async {
|
||||||
|
if (!_activeUploadPaths.remove(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_managedTempFilePath != path) {
|
||||||
|
await cleanupTempFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isManagedTempFile(String path) {
|
||||||
|
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class ViewIntentAssetResolver {
|
||||||
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
final TimelineFactory _timelineFactory;
|
||||||
|
static final Logger _logger = Logger('ViewIntentAssetResolver');
|
||||||
|
|
||||||
|
const ViewIntentAssetResolver({required this._localAssetRepository, required this._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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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: localAsset == null ? path : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return LocalAsset(
|
||||||
|
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/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.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/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
@ -182,9 +183,11 @@ class LoginForm extends HookConsumerWidget {
|
||||||
|
|
||||||
Future<void> handleSyncFlow() async {
|
Future<void> handleSyncFlow() async {
|
||||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||||
|
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||||
|
|
||||||
await backgroundManager.syncLocal(full: true);
|
await backgroundManager.syncLocal(full: true);
|
||||||
await backgroundManager.syncRemote();
|
await backgroundManager.syncRemote();
|
||||||
|
await viewIntentHandler.flushDeferredViewIntent();
|
||||||
await backgroundManager.hashAssets();
|
await backgroundManager.hashAssets();
|
||||||
|
|
||||||
if (SettingsRepository.instance.appConfig.backup.syncAlbums) {
|
if (SettingsRepository.instance.appConfig.backup.syncAlbums) {
|
||||||
|
|
@ -259,7 +262,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
unawaited(handleSyncFlow());
|
unawaited(handleSyncFlow());
|
||||||
ref.read(websocketProvider.notifier).connect();
|
ref.read(websocketProvider.notifier).connect();
|
||||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -346,7 +349,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
await getManageMediaPermission();
|
await getManageMediaPermission();
|
||||||
}
|
}
|
||||||
unawaited(handleSyncFlow());
|
unawaited(handleSyncFlow());
|
||||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ run = [
|
||||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||||
"dart run pigeon --input pigeon/network_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"]
|
[tasks."codegen:translation"]
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart';
|
||||||
dartOut: 'lib/platform/local_image_api.g.dart',
|
dartOut: 'lib/platform/local_image_api.g.dart',
|
||||||
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
|
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
|
||||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||||
kotlinOut:
|
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
|
||||||
'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
|
|
||||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
|
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
|
||||||
dartOptions: DartOptions(),
|
dartOptions: DartOptions(),
|
||||||
dartPackageName: 'immich_mobile',
|
dartPackageName: 'immich_mobile',
|
||||||
|
|
|
||||||
|
|
@ -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,261 @@
|
||||||
|
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/view_intent/view_intent_handler_android.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),
|
||||||
|
authProvider.overrideWith((ref) {
|
||||||
|
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
|
||||||
|
return authNotifier;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
|
||||||
|
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 consumes the pending attachment and routes the viewer', (tester) async {
|
||||||
|
authNotifier.setAuthenticated(false);
|
||||||
|
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||||
|
authNotifier.setAuthenticated(true);
|
||||||
|
|
||||||
|
when(() => resolver.resolve(payload)).thenAnswer((_) async {
|
||||||
|
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
|
||||||
|
});
|
||||||
|
|
||||||
|
unawaited(handler.flushDeferredViewIntent());
|
||||||
|
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);
|
||||||
|
// Spin a few async ticks so the internal bucket subscription has populated
|
||||||
|
// the buffer before tests start asserting against totalAssets.
|
||||||
|
for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
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,119 @@
|
||||||
|
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 defers deletion while an upload is active', () async {
|
||||||
|
final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes');
|
||||||
|
|
||||||
|
service.markUploadActive(tempFile.path);
|
||||||
|
await service.cleanupTempFile(tempFile.path);
|
||||||
|
|
||||||
|
expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup');
|
||||||
|
|
||||||
|
await service.markUploadInactive(tempFile.path);
|
||||||
|
expect(await tempFile.exists(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanupTempFile ignores non-managed paths', () async {
|
||||||
|
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanupStaleTempFiles skips paths with active uploads', () async {
|
||||||
|
final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale');
|
||||||
|
final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active');
|
||||||
|
service.markUploadActive(active.path);
|
||||||
|
|
||||||
|
await service.cleanupStaleTempFiles();
|
||||||
|
|
||||||
|
expect(await stale.exists(), isFalse);
|
||||||
|
expect(await active.exists(), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue