mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 08:02:29 -04:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0281de7ff6 | |||
| 43554fc6cf | |||
| 7015e511e8 | |||
| 96420bbf04 | |||
| f4e275a257 | |||
| 561fe231ac | |||
| 6b291c469e | |||
| 7d5be4317f | |||
| eee3d2ce61 | |||
| e2f5308cba | |||
| d96cb8d386 | |||
| 2c9639f18b | |||
| 880155916f | |||
| 84854a8575 | |||
| fde0959579 | |||
| ca203726dc | |||
| 5d33870403 | |||
| 0276e86895 | |||
| 90d9d0075a | |||
| 6b7b029562 | |||
| 7adc568575 | |||
| f5dd2cfb18 | |||
| 8c143d36ef | |||
| 45411f38e8 | |||
| 28dda8e2d5 | |||
| dc15af4e69 | |||
| 2775a09dc5 | |||
| 80c9796abe | |||
| 66a3aa27b5 | |||
| 275c324e8d | |||
| 4354431327 | |||
| 0d4d59c7e7 | |||
| b3b0b0f576 | |||
| 4806dc76aa | |||
| 719c7d955b | |||
| 175f8d99de | |||
| fb66f53410 | |||
| 136379a882 | |||
| c35c948f63 | |||
| bc301a3aac | |||
| 3ab68a4bf8 | |||
| 66c6daeded | |||
| bb803f13da | |||
| bda0ceb2e2 | |||
| ef80a8e936 |
@@ -230,10 +230,6 @@ jobs:
|
||||
- name: Generate platform APIs
|
||||
run: mise //mobile:codegen:pigeon
|
||||
|
||||
- name: Resolve iOS Swift Packages
|
||||
working-directory: ./mobile
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
with:
|
||||
|
||||
@@ -1,31 +1,9 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools."aqua:flutter/flutter"]]
|
||||
version = "3.44.0"
|
||||
version = "3.41.9"
|
||||
backend = "aqua:flutter/flutter"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
|
||||
checksum = "blake3:fb03aa5d9790205c948922ec3f0751c16e4575b09d6ae9dd4fbeb664a69f0e00"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.0-stable.zip"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.0-stable.zip"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.0-stable.zip"
|
||||
|
||||
[[tools.flutter]]
|
||||
version = "3.41.9-stable"
|
||||
backend = "asdf:flutter"
|
||||
|
||||
@@ -16,7 +16,7 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
"aqua:flutter/flutter" = "3.44.0"
|
||||
"aqua:flutter/flutter" = "3.41.9"
|
||||
pnpm = "10.33.4"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.41.9",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [
|
||||
|
||||
@@ -89,6 +89,20 @@
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow Immich to act as an image viewer -->
|
||||
<intent-filter android:label="View in Immich">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow Immich to act as a video viewer -->
|
||||
<intent-filter android:label="View in Immich">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- immich:// URL scheme handling -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
@@ -22,6 +23,7 @@ import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
import app.alextran.immich.viewintent.ViewIntentPlugin
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
@@ -31,6 +33,11 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
registerPlugins(this, flutterEngine)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
HttpClientManager.initialize(ctx)
|
||||
@@ -55,6 +62,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(ViewIntentPlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
flutterEngine.plugins.add(permissionApiImpl)
|
||||
|
||||
+292
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package app.alextran.immich.viewintent
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TAG = "ViewIntentPlugin"
|
||||
|
||||
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
|
||||
private var context: Context? = null
|
||||
private var activity: Activity? = null
|
||||
private var pendingIntent: Intent? = null
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
|
||||
ioScope.cancel()
|
||||
context = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
pendingIntent = binding.activity.intent
|
||||
binding.addOnNewIntentListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent): Boolean {
|
||||
pendingIntent = intent
|
||||
return false
|
||||
}
|
||||
|
||||
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
|
||||
val context = context ?: run {
|
||||
callback(Result.success(null))
|
||||
return
|
||||
}
|
||||
val intent = pendingIntent ?: activity?.intent
|
||||
|
||||
if (intent?.action != Intent.ACTION_VIEW) {
|
||||
callback(Result.success(null))
|
||||
return
|
||||
}
|
||||
|
||||
val uri = intent.data
|
||||
if (uri == null) {
|
||||
callback(Result.success(null))
|
||||
return
|
||||
}
|
||||
|
||||
ioScope.launch {
|
||||
try {
|
||||
val mimeType = context.contentResolver.getType(uri) ?: intent.type
|
||||
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
|
||||
callback(Result.success(null))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val localAssetId = extractLocalAssetId(context, uri, mimeType)
|
||||
val tempFilePath = if (localAssetId == null) {
|
||||
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
|
||||
callback(Result.success(null))
|
||||
return@launch
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val payload = ViewIntentPayload(
|
||||
path = tempFilePath,
|
||||
mimeType = mimeType,
|
||||
localAssetId = localAssetId,
|
||||
)
|
||||
consumeViewIntent(intent)
|
||||
callback(Result.success(payload))
|
||||
} catch (e: Exception) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeViewIntent(currentIntent: Intent) {
|
||||
pendingIntent = Intent(currentIntent).apply {
|
||||
action = null
|
||||
data = null
|
||||
type = null
|
||||
}
|
||||
activity?.intent = pendingIntent
|
||||
}
|
||||
|
||||
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
|
||||
return tryExtractDocumentLocalAssetId(context, uri)
|
||||
?: tryParseContentUriId(uri)
|
||||
?: tryParseLastPathSegmentId(uri)
|
||||
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
|
||||
}
|
||||
|
||||
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
if (!DocumentsContract.isDocumentUri(context, uri)) return null
|
||||
val docId = DocumentsContract.getDocumentId(uri)
|
||||
if (docId.isBlank() || docId.startsWith("raw:")) return null
|
||||
val parsed = docId.substringAfter(':', docId)
|
||||
if (parsed.isNotEmpty() && parsed.all(Char::isDigit)) parsed else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseContentUriId(uri: Uri): String? {
|
||||
return try {
|
||||
val parsed = ContentUris.parseId(uri)
|
||||
if (parsed >= 0) parsed.toString() else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse local asset id from content URI: $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseLastPathSegmentId(uri: Uri): String? {
|
||||
val segment = uri.lastPathSegment ?: return null
|
||||
return if (segment.all(Char::isDigit)) segment else null
|
||||
}
|
||||
|
||||
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
|
||||
return try {
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
|
||||
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: return null
|
||||
tempFile
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
|
||||
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||
val (displayName, size) =
|
||||
try {
|
||||
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return null
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
|
||||
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
|
||||
if (name.isNullOrBlank() || bytes < 0) return null
|
||||
name to bytes
|
||||
} ?: return null
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val tableUri = when {
|
||||
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
} else {
|
||||
MediaStore.Files.getContentUri("external")
|
||||
}
|
||||
}
|
||||
return try {
|
||||
context.contentResolver
|
||||
.query(
|
||||
tableUri,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
|
||||
arrayOf(displayName, size.toString()),
|
||||
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return null
|
||||
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
||||
if (idIndex < 0) return null
|
||||
cursor.getLong(idIndex).toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,3 @@ android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
PODS:
|
||||
- background_downloader (0.0.1):
|
||||
- Flutter
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- flutter_udid (0.0.1):
|
||||
- Flutter
|
||||
- KeychainAccess
|
||||
- flutter_web_auth_2 (5.0.0):
|
||||
- Flutter
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- home_widget (0.0.1):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- KeychainAccess (4.2.2)
|
||||
- local_auth_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- MapLibre (6.14.0)
|
||||
- maplibre_gl (0.0.1):
|
||||
- Flutter
|
||||
- MapLibre (= 6.14.0)
|
||||
- native_video_player (1.0.0):
|
||||
- Flutter
|
||||
- network_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- photo_manager (3.9.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- share_handler_ios (0.0.14):
|
||||
- Flutter
|
||||
- share_handler_ios/share_handler_ios_models (= 0.0.14)
|
||||
@@ -26,56 +61,144 @@ PODS:
|
||||
- Flutter
|
||||
- share_handler_ios_models
|
||||
- share_handler_ios_models (0.0.9)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
|
||||
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
|
||||
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- KeychainAccess
|
||||
- MapLibre
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
background_downloader:
|
||||
:path: ".symlinks/plugins/background_downloader/ios"
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
flutter_udid:
|
||||
:path: ".symlinks/plugins/flutter_udid/ios"
|
||||
flutter_web_auth_2:
|
||||
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||
home_widget:
|
||||
:path: ".symlinks/plugins/home_widget/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
local_auth_darwin:
|
||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||
maplibre_gl:
|
||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||
native_video_player:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
network_info_plus:
|
||||
:path: ".symlinks/plugins/network_info_plus/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
photo_manager:
|
||||
:path: ".symlinks/plugins/photo_manager/darwin"
|
||||
share_handler_ios:
|
||||
:path: ".symlinks/plugins/share_handler_ios/ios"
|
||||
share_handler_ios_models:
|
||||
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
|
||||
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
|
||||
flutter_web_auth_2: 646fc9df97a01c59e5eea99b237da2c6360f8439
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
||||
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
|
||||
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
|
||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
||||
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
|
||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
|
||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
|
||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -130,7 +129,6 @@
|
||||
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
|
||||
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
|
||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -195,7 +193,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
|
||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
|
||||
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
|
||||
@@ -250,7 +247,6 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -364,9 +360,6 @@
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
@@ -470,7 +463,6 @@
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
||||
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
||||
);
|
||||
@@ -1293,17 +1285,7 @@
|
||||
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
|
||||
productName = StructuredFieldValues;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
+2
-19
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -18,24 +19,6 @@
|
||||
"version" : "7.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "keychainaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
|
||||
"state" : {
|
||||
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||
"version" : "4.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "maplibre-gl-native-distribution",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||
"state" : {
|
||||
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
|
||||
"version" : "6.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sqlite-data",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -163,5 +146,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
@@ -5,24 +5,6 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Immich.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -18,24 +19,6 @@
|
||||
"version" : "7.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "keychainaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
|
||||
"state" : {
|
||||
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||
"version" : "4.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "maplibre-gl-native-distribution",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||
"state" : {
|
||||
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
|
||||
"version" : "6.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sqlite-data",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -163,5 +146,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func cancelHashing() {
|
||||
hashTask?.cancel()
|
||||
hashTask = nil
|
||||
|
||||
@@ -8,7 +8,11 @@ class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
|
||||
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
|
||||
const AssetService({
|
||||
required RemoteAssetRepository remoteAssetRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
}) : _remoteAssetRepository = remoteAssetRepository,
|
||||
_localAssetRepository = localAssetRepository;
|
||||
|
||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
|
||||
@@ -61,9 +61,11 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
BackgroundWorkerBgService({required this._drift, required this._driftLogger})
|
||||
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
BackgroundWorkerBgService({required Drift drift, required DriftLogger driftLogger})
|
||||
: _drift = drift,
|
||||
_driftLogger = driftLogger,
|
||||
_backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(drift))]);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,13 +21,18 @@ class HashService {
|
||||
final _log = Logger('HashService');
|
||||
|
||||
HashService({
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
this._cancelChecker,
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
bool Function()? cancelChecker,
|
||||
int? batchSize,
|
||||
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_cancelChecker = cancelChecker,
|
||||
_nativeSyncApi = nativeSyncApi,
|
||||
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
|
||||
|
||||
@@ -28,13 +28,18 @@ class LocalSyncService {
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required this._localAlbumRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
});
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
|
||||
@@ -10,7 +10,7 @@ typedef MapQuery = ({MapMarkerSource markerSource});
|
||||
class MapFactory {
|
||||
final DriftMapRepository _mapRepository;
|
||||
|
||||
const MapFactory({required this._mapRepository});
|
||||
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
||||
|
||||
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
|
||||
MapService(_mapRepository.remote(ownerIds, options));
|
||||
|
||||
@@ -9,7 +9,7 @@ final AppSetting = SettingsService(storeService: StoreService.I);
|
||||
class SettingsService {
|
||||
final StoreService _storeService;
|
||||
|
||||
const SettingsService({required this._storeService});
|
||||
const SettingsService({required StoreService storeService}) : _storeService = storeService;
|
||||
|
||||
T get<T>(Setting<T> setting) => _storeService.get(setting.storeKey, setting.defaultValue);
|
||||
|
||||
|
||||
@@ -41,16 +41,24 @@ class SyncStreamService {
|
||||
final bool Function()? _cancelChecker;
|
||||
|
||||
SyncStreamService({
|
||||
required this._syncApiRepository,
|
||||
required this._syncStreamRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._assetMediaRepository,
|
||||
required this._permissionRepository,
|
||||
required this._syncMigrationRepository,
|
||||
required this._api,
|
||||
this._cancelChecker,
|
||||
});
|
||||
required SyncApiRepository syncApiRepository,
|
||||
required SyncStreamRepository syncStreamRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required SyncMigrationRepository syncMigrationRepository,
|
||||
required ApiService api,
|
||||
bool Function()? cancelChecker,
|
||||
}) : _syncApiRepository = syncApiRepository,
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_syncMigrationRepository = syncMigrationRepository,
|
||||
_api = api,
|
||||
_cancelChecker = cancelChecker;
|
||||
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ typedef TimelineBucketSource = Stream<List<Bucket>> Function();
|
||||
|
||||
typedef TimelineQuery = ({TimelineAssetSource assetSource, TimelineBucketSource bucketSource, TimelineOrigin origin});
|
||||
|
||||
enum TimelineStatus { uninitialized, ready, disposed }
|
||||
|
||||
enum TimelineOrigin {
|
||||
main,
|
||||
localAlbum,
|
||||
@@ -41,7 +43,11 @@ class TimelineFactory {
|
||||
final DriftTimelineRepository _timelineRepository;
|
||||
final MetadataRepository _metadataRepository;
|
||||
|
||||
const TimelineFactory({required this._timelineRepository, required this._metadataRepository});
|
||||
const TimelineFactory({
|
||||
required DriftTimelineRepository timelineRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
}) : _timelineRepository = timelineRepository,
|
||||
_metadataRepository = metadataRepository;
|
||||
|
||||
GroupAssetsBy get groupBy {
|
||||
final group = _metadataRepository.appConfig.timeline.groupAssetsBy;
|
||||
@@ -97,14 +103,23 @@ class TimelineService {
|
||||
int _bufferOffset = 0;
|
||||
List<BaseAsset> _buffer = [];
|
||||
StreamSubscription? _bucketSubscription;
|
||||
final StreamController<TimelineStatus> _statusController = StreamController<TimelineStatus>.broadcast();
|
||||
|
||||
int _totalAssets = 0;
|
||||
int get totalAssets => _totalAssets;
|
||||
TimelineStatus _status = TimelineStatus.uninitialized;
|
||||
TimelineStatus get status => _status;
|
||||
bool get isReady => _status == TimelineStatus.ready;
|
||||
|
||||
TimelineService(TimelineQuery query)
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
|
||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||
TimelineService._({
|
||||
required TimelineAssetSource assetSource,
|
||||
required TimelineBucketSource bucketSource,
|
||||
required this.origin,
|
||||
}) : _assetSource = assetSource,
|
||||
_bucketSource = bucketSource {
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
@@ -130,12 +145,17 @@ class TimelineService {
|
||||
|
||||
// change the state's total assets count only after the buffer is reloaded
|
||||
_totalAssets = totalAssets;
|
||||
if (_status == TimelineStatus.uninitialized) {
|
||||
_status = TimelineStatus.ready;
|
||||
_statusController.add(_status);
|
||||
}
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||
Stream<TimelineStatus> watchStatus() => _statusController.stream;
|
||||
|
||||
Future<List<BaseAsset>> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count));
|
||||
|
||||
@@ -238,5 +258,12 @@ class TimelineService {
|
||||
_bucketSubscription = null;
|
||||
_buffer = [];
|
||||
_bufferOffset = 0;
|
||||
if (_status != TimelineStatus.disposed) {
|
||||
_status = TimelineStatus.disposed;
|
||||
if (!_statusController.isClosed) {
|
||||
_statusController.add(_status);
|
||||
}
|
||||
}
|
||||
await _statusController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ class UserService {
|
||||
final UserApiRepository _userApiRepository;
|
||||
final StoreService _storeService;
|
||||
|
||||
UserService({required this._userApiRepository, required this._storeService});
|
||||
UserService({required UserApiRepository userApiRepository, required StoreService storeService})
|
||||
: _userApiRepository = userApiRepository,
|
||||
_storeService = storeService;
|
||||
|
||||
UserDto getMyUser() {
|
||||
return _storeService.get(StoreKey.currentUser);
|
||||
|
||||
@@ -678,6 +678,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
List<Bucket> _generateBuckets(int count) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
@@ -128,6 +129,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
case AppLifecycleState.resumed:
|
||||
dPrint(() => "[APP STATE] resumed");
|
||||
ref.read(appStateProvider.notifier).handleAppResume();
|
||||
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
dPrint(() => "[APP STATE] inactive");
|
||||
@@ -233,6 +235,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
}
|
||||
});
|
||||
|
||||
ref.read(viewIntentHandlerProvider).init();
|
||||
ref.read(shareIntentUploadProvider.notifier).init();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
extension ViewIntentPayloadX on ViewIntentPayload {
|
||||
String get fileName {
|
||||
final resolvedPath = path;
|
||||
if (resolvedPath != null && resolvedPath.isNotEmpty) {
|
||||
return basename(resolvedPath);
|
||||
}
|
||||
return localAssetId ?? 'view_intent_asset';
|
||||
}
|
||||
|
||||
bool get isImage => mimeType.toLowerCase().startsWith('image/');
|
||||
|
||||
bool get isVideo => mimeType.toLowerCase().startsWith('video/');
|
||||
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) {
|
||||
return AssetPlaybackStyle.video;
|
||||
}
|
||||
|
||||
final normalizedMimeType = mimeType.toLowerCase();
|
||||
if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
|
||||
final normalizedPath = path?.toLowerCase();
|
||||
if (normalizedPath != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
|
||||
return AssetPlaybackStyle.image;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
@@ -314,6 +315,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final wsProvider = ref.read(websocketProvider.notifier);
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||
|
||||
unawaited(
|
||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||
@@ -328,6 +330,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||
]);
|
||||
|
||||
await viewIntentHandler.flushDeferredViewIntent();
|
||||
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
backgroundManager.hashAssets().then((_) {
|
||||
|
||||
+170
-126
@@ -9,14 +9,22 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
Object? _extractReplyValueOrThrow(
|
||||
List<Object?>? replyList,
|
||||
String channelName, {
|
||||
required bool isNullValid,
|
||||
}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
throw PlatformException(
|
||||
code: replyList[0]! as String,
|
||||
message: replyList[1] as String?,
|
||||
details: replyList[2],
|
||||
);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
@@ -37,7 +45,9 @@ bool _deepEquals(Object? a, Object? b) {
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
@@ -86,7 +96,15 @@ int _deepHash(Object? value) {
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum PlatformAssetPlaybackStyle {
|
||||
unknown,
|
||||
image,
|
||||
video,
|
||||
imageAnimated,
|
||||
livePhoto,
|
||||
videoLooping,
|
||||
}
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
@@ -154,8 +172,7 @@ class PlatformAsset {
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static PlatformAsset decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -186,20 +203,7 @@ class PlatformAsset {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) &&
|
||||
_deepEquals(name, other.name) &&
|
||||
_deepEquals(type, other.type) &&
|
||||
_deepEquals(createdAt, other.createdAt) &&
|
||||
_deepEquals(updatedAt, other.updatedAt) &&
|
||||
_deepEquals(width, other.width) &&
|
||||
_deepEquals(height, other.height) &&
|
||||
_deepEquals(durationMs, other.durationMs) &&
|
||||
_deepEquals(orientation, other.orientation) &&
|
||||
_deepEquals(isFavorite, other.isFavorite) &&
|
||||
_deepEquals(adjustmentTime, other.adjustmentTime) &&
|
||||
_deepEquals(latitude, other.latitude) &&
|
||||
_deepEquals(longitude, other.longitude) &&
|
||||
_deepEquals(playbackStyle, other.playbackStyle);
|
||||
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(createdAt, other.createdAt) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(width, other.width) && _deepEquals(height, other.height) && _deepEquals(durationMs, other.durationMs) && _deepEquals(orientation, other.orientation) && _deepEquals(isFavorite, other.isFavorite) && _deepEquals(adjustmentTime, other.adjustmentTime) && _deepEquals(latitude, other.latitude) && _deepEquals(longitude, other.longitude) && _deepEquals(playbackStyle, other.playbackStyle);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -227,12 +231,17 @@ class PlatformAlbum {
|
||||
int assetCount;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[id, name, updatedAt, isCloud, assetCount];
|
||||
return <Object?>[
|
||||
id,
|
||||
name,
|
||||
updatedAt,
|
||||
isCloud,
|
||||
assetCount,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static PlatformAlbum decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -254,11 +263,7 @@ class PlatformAlbum {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) &&
|
||||
_deepEquals(name, other.name) &&
|
||||
_deepEquals(updatedAt, other.updatedAt) &&
|
||||
_deepEquals(isCloud, other.isCloud) &&
|
||||
_deepEquals(assetCount, other.assetCount);
|
||||
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(isCloud, other.isCloud) && _deepEquals(assetCount, other.assetCount);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -267,7 +272,12 @@ class PlatformAlbum {
|
||||
}
|
||||
|
||||
class SyncDelta {
|
||||
SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums});
|
||||
SyncDelta({
|
||||
required this.hasChanges,
|
||||
required this.updates,
|
||||
required this.deletes,
|
||||
required this.assetAlbums,
|
||||
});
|
||||
|
||||
bool hasChanges;
|
||||
|
||||
@@ -278,12 +288,16 @@ class SyncDelta {
|
||||
Map<String, List<String>> assetAlbums;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[hasChanges, updates, deletes, assetAlbums];
|
||||
return <Object?>[
|
||||
hasChanges,
|
||||
updates,
|
||||
deletes,
|
||||
assetAlbums,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static SyncDelta decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -304,10 +318,7 @@ class SyncDelta {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(hasChanges, other.hasChanges) &&
|
||||
_deepEquals(updates, other.updates) &&
|
||||
_deepEquals(deletes, other.deletes) &&
|
||||
_deepEquals(assetAlbums, other.assetAlbums);
|
||||
return _deepEquals(hasChanges, other.hasChanges) && _deepEquals(updates, other.updates) && _deepEquals(deletes, other.deletes) && _deepEquals(assetAlbums, other.assetAlbums);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -316,7 +327,11 @@ class SyncDelta {
|
||||
}
|
||||
|
||||
class HashResult {
|
||||
HashResult({required this.assetId, this.error, this.hash});
|
||||
HashResult({
|
||||
required this.assetId,
|
||||
this.error,
|
||||
this.hash,
|
||||
});
|
||||
|
||||
String assetId;
|
||||
|
||||
@@ -325,16 +340,23 @@ class HashResult {
|
||||
String? hash;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[assetId, error, hash];
|
||||
return <Object?>[
|
||||
assetId,
|
||||
error,
|
||||
hash,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static HashResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
|
||||
return HashResult(
|
||||
assetId: result[0]! as String,
|
||||
error: result[1] as String?,
|
||||
hash: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -355,7 +377,11 @@ class HashResult {
|
||||
}
|
||||
|
||||
class CloudIdResult {
|
||||
CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||
CloudIdResult({
|
||||
required this.assetId,
|
||||
this.error,
|
||||
this.cloudId,
|
||||
});
|
||||
|
||||
String assetId;
|
||||
|
||||
@@ -364,16 +390,23 @@ class CloudIdResult {
|
||||
String? cloudId;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[assetId, error, cloudId];
|
||||
return <Object?>[
|
||||
assetId,
|
||||
error,
|
||||
cloudId,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static CloudIdResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?);
|
||||
return CloudIdResult(
|
||||
assetId: result[0]! as String,
|
||||
error: result[1] as String?,
|
||||
cloudId: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -385,9 +418,7 @@ class CloudIdResult {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(assetId, other.assetId) &&
|
||||
_deepEquals(error, other.error) &&
|
||||
_deepEquals(cloudId, other.cloudId);
|
||||
return _deepEquals(assetId, other.assetId) && _deepEquals(error, other.error) && _deepEquals(cloudId, other.cloudId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -395,6 +426,7 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -402,22 +434,22 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
@@ -452,8 +484,8 @@ class NativeSyncApi {
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
@@ -461,8 +493,7 @@ class NativeSyncApi {
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<bool> shouldFullSync() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -472,16 +503,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<SyncDelta> getMediaChanges() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -491,16 +522,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as SyncDelta;
|
||||
}
|
||||
|
||||
Future<void> checkpointSync() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -509,12 +540,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<void> clearSyncCheckpoint() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -523,12 +558,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<List<String>> getAssetIdsForAlbum(String albumId) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -538,16 +577,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
||||
}
|
||||
|
||||
Future<List<PlatformAlbum>> getAlbums() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -557,16 +596,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
|
||||
}
|
||||
|
||||
Future<int> getAssetsCountSince(String albumId, int timestamp) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -576,16 +615,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as int;
|
||||
}
|
||||
|
||||
Future<List<PlatformAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -595,16 +634,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
|
||||
}
|
||||
|
||||
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -614,16 +653,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
|
||||
}
|
||||
|
||||
Future<void> cancelHashing() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -632,12 +671,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -647,16 +690,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -666,16 +709,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -685,10 +728,11 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||
}
|
||||
}
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (identical(a, b)) {
|
||||
return true;
|
||||
}
|
||||
if (a is double && b is double) {
|
||||
if (a.isNaN && b.isNaN) {
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (final MapEntry<Object?, Object?> entryA in a.entries) {
|
||||
bool found = false;
|
||||
for (final MapEntry<Object?, Object?> entryB in b.entries) {
|
||||
if (_deepEquals(entryA.key, entryB.key)) {
|
||||
if (_deepEquals(entryA.value, entryB.value)) {
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
int _deepHash(Object? value) {
|
||||
if (value is List) {
|
||||
return Object.hashAll(value.map(_deepHash));
|
||||
}
|
||||
if (value is Map) {
|
||||
int result = 0;
|
||||
for (final MapEntry<Object?, Object?> entry in value.entries) {
|
||||
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value is double && value.isNaN) {
|
||||
// Normalize NaN to a consistent hash.
|
||||
return 0x7FF8000000000000.hashCode;
|
||||
}
|
||||
if (value is double && value == 0.0) {
|
||||
// Normalize -0.0 to 0.0 so they have the same hash code.
|
||||
return 0.0.hashCode;
|
||||
}
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
class ViewIntentPayload {
|
||||
ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
|
||||
|
||||
String? path;
|
||||
|
||||
String mimeType;
|
||||
|
||||
String? localAssetId;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[path, mimeType, localAssetId];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static ViewIntentPayload decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return ViewIntentPayload(
|
||||
path: result[0] as String?,
|
||||
mimeType: result[1]! as String,
|
||||
localAssetId: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ViewIntentPayload || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(path, other.path) &&
|
||||
_deepEquals(mimeType, other.mimeType) &&
|
||||
_deepEquals(localAssetId, other.localAssetId);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is ViewIntentPayload) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
return ViewIntentPayload.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewIntentHostApi {
|
||||
/// Constructor for [ViewIntentHostApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<ViewIntentPayload?> consumeViewIntent() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as ViewIntentPayload?;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MainTimelinePage extends ConsumerWidget {
|
||||
class MainTimelinePage extends HookConsumerWidget {
|
||||
const MainTimelinePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
useEffect(() {
|
||||
unawaited(Future<void>(() => ref.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce()));
|
||||
return null;
|
||||
}, const []);
|
||||
|
||||
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
return Timeline(
|
||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
@@ -7,9 +8,11 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
@@ -26,7 +29,11 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final isTimeline = source == ActionSource.timeline;
|
||||
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
|
||||
List<LocalAsset>? assets;
|
||||
var isUploadDialogOpen = false;
|
||||
var wasUploadCancelled = false;
|
||||
Future<void>? uploadDialogFuture;
|
||||
|
||||
if (source == ActionSource.timeline) {
|
||||
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
||||
@@ -35,22 +42,44 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
} else {
|
||||
unawaited(
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => const _UploadProgressDialog(),
|
||||
),
|
||||
);
|
||||
isUploadDialogOpen = true;
|
||||
uploadDialogFuture =
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => _UploadProgressDialog(
|
||||
onCancel: () {
|
||||
wasUploadCancelled = true;
|
||||
},
|
||||
),
|
||||
).whenComplete(() {
|
||||
isUploadDialogOpen = false;
|
||||
});
|
||||
unawaited(uploadDialogFuture);
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
var success = false;
|
||||
if (!isTimeline && viewerIntentFilePath != null) {
|
||||
var hasError = false;
|
||||
await ref
|
||||
.read(foregroundUploadServiceProvider)
|
||||
.uploadShareIntent(
|
||||
[File(viewerIntentFilePath)],
|
||||
onError: (fileId, errorMessage) {
|
||||
hasError = true;
|
||||
},
|
||||
);
|
||||
success = !hasError;
|
||||
} else {
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
success = result.success;
|
||||
}
|
||||
|
||||
if (!isTimeline && context.mounted) {
|
||||
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (context.mounted && !result.success) {
|
||||
if (context.mounted && !success && !wasUploadCancelled) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
@@ -73,7 +102,9 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _UploadProgressDialog extends ConsumerWidget {
|
||||
const _UploadProgressDialog();
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _UploadProgressDialog({required this.onCancel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -103,7 +134,8 @@ class _UploadProgressDialog extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
Navigator.of(context).pop();
|
||||
onCancel();
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
labelText: 'cancel'.t(context: context),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
@@ -323,14 +325,18 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
required PhotoViewHeroAttributes? heroAttributes,
|
||||
required bool isCurrent,
|
||||
required bool isPlayingMotionVideo,
|
||||
required String? localFilePath,
|
||||
}) {
|
||||
final size = context.sizeData;
|
||||
final imageProvider = localFilePath != null
|
||||
? FileImage(File(localFilePath))
|
||||
: getFullImageProvider(asset, size: size);
|
||||
|
||||
if (asset.isImage && !isPlayingMotionVideo) {
|
||||
return PhotoView(
|
||||
key: Key(asset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
imageProvider: imageProvider,
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
gaplessPlayback: true,
|
||||
@@ -377,12 +383,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
localFilePath: localFilePath,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(
|
||||
image: getFullImageProvider(asset, size: size),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -393,6 +396,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||
|
||||
final asset = _asset;
|
||||
if (asset == null) {
|
||||
@@ -421,6 +425,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
@@ -440,6 +446,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
: null,
|
||||
isCurrent: isCurrent,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
localFilePath: viewIntentFilePath,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
|
||||
@@ -64,18 +64,7 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
ConsumerState createState() => _AssetViewerState();
|
||||
|
||||
static void setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
ref.read(assetViewerProvider.notifier).reset();
|
||||
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
prepareAssetViewerState(ref.read(assetViewerProvider.notifier), asset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +78,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
bool _disposeStarted = false;
|
||||
|
||||
void _onTapNavigate(int direction) {
|
||||
final page = _pageController.page?.toInt();
|
||||
@@ -123,6 +113,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeStarted = true;
|
||||
_pageController.dispose();
|
||||
_preloader.dispose();
|
||||
_reloadSubscription?.cancel();
|
||||
@@ -160,14 +151,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_currentPage = index;
|
||||
|
||||
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
|
||||
if (asset == null) {
|
||||
if (!mounted || asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
AssetViewer._setAsset(ref, asset);
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
_preloader.preload(index, context.sizeData);
|
||||
_handleCasting();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
@@ -203,6 +197,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
if (!mounted || _disposeStarted) {
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case TimelineReloadEvent():
|
||||
_onTimelineReloadEvent();
|
||||
@@ -226,13 +223,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
void _onTimelineReloadEvent() {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final totalAssets = timelineService.totalAssets;
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
final isViewerTransitionInProgress = ref.read(
|
||||
assetViewerProvider.select((value) => value.isViewerTransitionInProgress),
|
||||
);
|
||||
|
||||
if (isViewerTransitionInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalAssets == 0) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
|
||||
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -19,6 +20,7 @@ import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final String? localFilePath;
|
||||
final bool isCurrent;
|
||||
final bool showControls;
|
||||
final Widget image;
|
||||
@@ -26,6 +28,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.localFilePath,
|
||||
required this.image,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
@@ -106,6 +109,19 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
}
|
||||
|
||||
try {
|
||||
final localFilePath = widget.localFilePath;
|
||||
if (localFilePath != null) {
|
||||
final file = File(localFilePath);
|
||||
if (!await file.exists()) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
|
||||
return VideoSource.init(
|
||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
}
|
||||
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
|
||||
@@ -296,12 +296,16 @@ class _ThumbnailRenderBox extends RenderBox {
|
||||
bool isRepaintBoundary = true;
|
||||
|
||||
_ThumbnailRenderBox({
|
||||
required this._image,
|
||||
required this._previousImage,
|
||||
required this._fadeValue,
|
||||
required this._fit,
|
||||
required this._placeholderGradient,
|
||||
});
|
||||
required ui.Image? image,
|
||||
required ui.Image? previousImage,
|
||||
required double fadeValue,
|
||||
required BoxFit fit,
|
||||
required Gradient placeholderGradient,
|
||||
}) : _image = image,
|
||||
_previousImage = previousImage,
|
||||
_fadeValue = fadeValue,
|
||||
_fit = fit,
|
||||
_placeholderGradient = placeholderGradient;
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
|
||||
@@ -62,11 +62,14 @@ class RenderFixedRow extends RenderBox
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required this._height,
|
||||
required this._widths,
|
||||
required this._spacing,
|
||||
required this._textDirection,
|
||||
}) {
|
||||
required double height,
|
||||
required List<double> widths,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _height = height,
|
||||
_widths = widths,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
|
||||
@@ -578,7 +578,9 @@ class _SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> _animation;
|
||||
final Widget _child;
|
||||
|
||||
const _SlideFadeTransition({required this._animation, required this._child});
|
||||
const _SlideFadeTransition({required Animation<double> animation, required Widget child})
|
||||
: _animation = animation,
|
||||
_child = child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -397,7 +397,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final grid = CustomScrollView(
|
||||
primary: true,
|
||||
physics: _scrollPhysics,
|
||||
scrollCacheExtent: .pixels(maxHeight * 2),
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
@@ -503,7 +503,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<Segment> _segments;
|
||||
|
||||
const _SliverSegmentedList({required this._segments, required super.delegate});
|
||||
const _SliverSegmentedList({required List<Segment> segments, required super.delegate}) : _segments = segments;
|
||||
|
||||
@override
|
||||
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
|
||||
@@ -527,7 +527,8 @@ class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
_RenderSliverTimelineBoxAdaptor({required super.childManager, required this._segments});
|
||||
_RenderSliverTimelineBoxAdaptor({required super.childManager, required List<Segment> segments})
|
||||
: _segments = segments;
|
||||
|
||||
int getMinChildIndexForScrollOffset(double offset) =>
|
||||
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ?? 0;
|
||||
|
||||
@@ -10,6 +10,7 @@ class AssetViewerState {
|
||||
final bool isZoomed;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
final bool isViewerTransitionInProgress;
|
||||
|
||||
const AssetViewerState({
|
||||
this.backgroundOpacity = 1.0,
|
||||
@@ -18,6 +19,7 @@ class AssetViewerState {
|
||||
this.isZoomed = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
this.isViewerTransitionInProgress = false,
|
||||
});
|
||||
|
||||
AssetViewerState copyWith({
|
||||
@@ -27,6 +29,7 @@ class AssetViewerState {
|
||||
bool? isZoomed,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
bool? isViewerTransitionInProgress,
|
||||
}) {
|
||||
return AssetViewerState(
|
||||
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||
@@ -35,6 +38,7 @@ class AssetViewerState {
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
isViewerTransitionInProgress: isViewerTransitionInProgress ?? this.isViewerTransitionInProgress,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +61,8 @@ class AssetViewerState {
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
other.stackIndex == stackIndex &&
|
||||
other.isViewerTransitionInProgress == isViewerTransitionInProgress;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -67,7 +72,8 @@ class AssetViewerState {
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
stackIndex.hashCode ^
|
||||
isViewerTransitionInProgress.hashCode;
|
||||
}
|
||||
|
||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
@@ -137,10 +143,28 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
|
||||
void setViewerTransitionInProgress(bool isInProgress) {
|
||||
if (isInProgress == state.isViewerTransitionInProgress) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isViewerTransitionInProgress: isInProgress);
|
||||
}
|
||||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
void prepareAssetViewerState(AssetViewerStateNotifier notifier, BaseAsset asset) {
|
||||
notifier.reset();
|
||||
|
||||
// Hide controls by default for videos before the viewer is shown.
|
||||
if (asset.isVideo) {
|
||||
notifier.setControls(false);
|
||||
}
|
||||
|
||||
notifier.setAsset(asset);
|
||||
}
|
||||
|
||||
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
((ref) => ShareIntentUploadStateNotifier(
|
||||
ref.watch(appRouterProvider),
|
||||
ref.read(foregroundUploadServiceProvider),
|
||||
ref.read(shareIntentServiceProvider),
|
||||
)),
|
||||
);
|
||||
|
||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||
final AppRouter router;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
void init() {
|
||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||
_shareIntentService.init();
|
||||
}
|
||||
|
||||
void onSharedMedia(List<ShareIntentAttachment> attachments) {
|
||||
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
||||
clearAttachments();
|
||||
addAttachments(attachments);
|
||||
router.push(ShareIntentRoute(attachments: attachments));
|
||||
}
|
||||
|
||||
void addAttachments(List<ShareIntentAttachment> attachments) {
|
||||
if (attachments.isEmpty) {
|
||||
return;
|
||||
}
|
||||
state = [...state, ...attachments];
|
||||
}
|
||||
|
||||
void removeAttachment(ShareIntentAttachment attachment) {
|
||||
final updatedState = state.where((element) => element != attachment).toList();
|
||||
if (updatedState.length != state.length) {
|
||||
state = updatedState;
|
||||
}
|
||||
}
|
||||
|
||||
void clearAttachments() {
|
||||
if (state.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = [];
|
||||
}
|
||||
|
||||
Future<void> uploadAll(List<File> files) async {
|
||||
for (final file in files) {
|
||||
final fileId = p.hash(file.path).toString();
|
||||
_updateStatus(fileId, UploadStatus.running);
|
||||
}
|
||||
|
||||
await _foregroundUploadService.uploadShareIntent(
|
||||
files,
|
||||
onProgress: (fileId, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
_updateProgress(fileId, progress);
|
||||
},
|
||||
onSuccess: (fileId) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id)
|
||||
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
||||
else
|
||||
attachment,
|
||||
];
|
||||
}
|
||||
|
||||
void _updateProgress(String fileId, double progress) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
||||
];
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
((ref) => ShareIntentUploadStateNotifier(
|
||||
ref.watch(appRouterProvider),
|
||||
ref.read(foregroundUploadServiceProvider),
|
||||
ref.read(shareIntentServiceProvider),
|
||||
)),
|
||||
);
|
||||
|
||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||
final AppRouter router;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
void init() {
|
||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||
_shareIntentService.init();
|
||||
}
|
||||
|
||||
void onSharedMedia(List<ShareIntentAttachment> attachments) {
|
||||
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
||||
clearAttachments();
|
||||
addAttachments(attachments);
|
||||
router.push(ShareIntentRoute(attachments: attachments));
|
||||
}
|
||||
|
||||
void addAttachments(List<ShareIntentAttachment> attachments) {
|
||||
if (attachments.isEmpty) {
|
||||
return;
|
||||
}
|
||||
state = [...state, ...attachments];
|
||||
}
|
||||
|
||||
void removeAttachment(ShareIntentAttachment attachment) {
|
||||
final updatedState = state.where((element) => element != attachment).toList();
|
||||
if (updatedState.length != state.length) {
|
||||
state = updatedState;
|
||||
}
|
||||
}
|
||||
|
||||
void clearAttachments() {
|
||||
if (state.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = [];
|
||||
}
|
||||
|
||||
Future<void> uploadAll(List<File> files) async {
|
||||
for (final file in files) {
|
||||
final fileId = p.hash(file.path).toString();
|
||||
_updateStatus(fileId, UploadStatus.running);
|
||||
}
|
||||
|
||||
await _foregroundUploadService.uploadShareIntent(
|
||||
files,
|
||||
onProgress: (fileId, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
_updateProgress(fileId, progress);
|
||||
},
|
||||
onSuccess: (fileId, _) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id)
|
||||
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
||||
else
|
||||
attachment,
|
||||
];
|
||||
}
|
||||
|
||||
void _updateProgress(String fileId, double progress) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,12 @@ class ActionResult {
|
||||
final int count;
|
||||
final bool success;
|
||||
final String? error;
|
||||
final List<String> remoteAssetIds;
|
||||
|
||||
const ActionResult({required this.count, required this.success, this.error});
|
||||
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
|
||||
|
||||
@override
|
||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
|
||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
|
||||
}
|
||||
|
||||
class ActionNotifier extends Notifier<void> {
|
||||
@@ -489,10 +490,14 @@ class ActionNotifier extends Notifier<void> {
|
||||
|
||||
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
||||
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
|
||||
if (assetsToUpload.isEmpty) {
|
||||
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
|
||||
}
|
||||
|
||||
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
||||
final cancelToken = Completer<void>();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||
final remoteAssetIds = <String>[];
|
||||
|
||||
// Initialize progress for all assets
|
||||
for (final asset in assetsToUpload) {
|
||||
@@ -509,6 +514,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
progressNotifier.setProgress(localAssetId, progress);
|
||||
},
|
||||
onSuccess: (localAssetId, remoteAssetId) {
|
||||
remoteAssetIds.add(remoteAssetId);
|
||||
progressNotifier.remove(localAssetId);
|
||||
},
|
||||
onError: (localAssetId, errorMessage) {
|
||||
@@ -516,7 +522,14 @@ class ActionNotifier extends Notifier<void> {
|
||||
},
|
||||
),
|
||||
);
|
||||
return ActionResult(count: assetsToUpload.length, success: true);
|
||||
final uploadedCount = remoteAssetIds.length;
|
||||
final success = uploadedCount == assetsToUpload.length;
|
||||
return ActionResult(
|
||||
count: assetsToUpload.length,
|
||||
success: success,
|
||||
error: success ? null : 'Uploaded $uploadedCount/${assetsToUpload.length} assets successfully',
|
||||
remoteAssetIds: remoteAssetIds,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed manually upload assets', error, stack);
|
||||
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
|
||||
|
||||
@@ -41,3 +41,23 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
});
|
||||
|
||||
final timelineStatusProvider = StreamProvider.autoDispose.family<TimelineStatus, TimelineService>((
|
||||
ref,
|
||||
timelineService,
|
||||
) async* {
|
||||
yield timelineService.status;
|
||||
yield* timelineService.watchStatus();
|
||||
});
|
||||
|
||||
Future<void> waitForTimelineReady(TimelineService timelineService, Duration timeout) {
|
||||
if (timelineService.isReady) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return timelineService
|
||||
.watchStatus()
|
||||
.firstWhere((status) => status == TimelineStatus.ready)
|
||||
.timeout(timeout)
|
||||
.then((_) {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class ViewIntentFilePathNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void setPath(String path) {
|
||||
if (state == path) {
|
||||
return;
|
||||
}
|
||||
state = path;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
|
||||
void clearIfMatch(String path) {
|
||||
if (state != path) {
|
||||
return;
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
final viewIntentFilePathProvider = NotifierProvider<ViewIntentFilePathNotifier, String?>(
|
||||
ViewIntentFilePathNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'dart:io';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart';
|
||||
|
||||
abstract class ViewIntentHandler {
|
||||
void init();
|
||||
|
||||
Future<void> onAppResumed();
|
||||
|
||||
Future<void> flushDeferredViewIntent();
|
||||
|
||||
Future<void> handle(ViewIntentPayload attachment);
|
||||
}
|
||||
|
||||
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {
|
||||
if (Platform.isAndroid) {
|
||||
return AndroidViewIntentHandler(ref);
|
||||
}
|
||||
|
||||
return const StubViewIntentHandler();
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class AndroidViewIntentHandler implements ViewIntentHandler {
|
||||
final Ref _ref;
|
||||
final ViewIntentService _viewIntentService;
|
||||
final ViewIntentAssetResolver _viewIntentAssetResolver;
|
||||
final AppRouter _router;
|
||||
static final Logger _logger = Logger('ViewIntentHandler');
|
||||
|
||||
AndroidViewIntentHandler(Ref ref)
|
||||
: _ref = ref,
|
||||
_viewIntentService = ref.read(viewIntentServiceProvider),
|
||||
_viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
|
||||
_router = ref.watch(appRouterProvider);
|
||||
|
||||
@override
|
||||
void init() {
|
||||
// Covers cold start from a view intent before the first lifecycle "resumed".
|
||||
unawaited(onAppResumed());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAppResumed() => _checkForViewIntent();
|
||||
|
||||
@override
|
||||
Future<void> flushDeferredViewIntent() => _flushPending();
|
||||
|
||||
Future<void> _checkForViewIntent() async {
|
||||
final attachment = await _viewIntentService.consumeViewIntent();
|
||||
if (attachment != null) {
|
||||
await handle(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ref.read(viewIntentPendingProvider) == null) {
|
||||
await _viewIntentService.cleanupStaleTempFiles();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _flushPending() async {
|
||||
if (_ref.read(viewIntentPendingProvider) == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _ref.read(viewIntentMainTimelineReadyProvider.notifier).wait(timeout: const Duration(seconds: 3));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
_logger.info('flushPending, pendingAttachment:$pendingAttachment}');
|
||||
if (pendingAttachment != null) {
|
||||
await handle(pendingAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handle(ViewIntentPayload attachment) async {
|
||||
_logger.info(
|
||||
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
|
||||
);
|
||||
|
||||
if (!_ref.read(authProvider).isAuthenticated) {
|
||||
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
|
||||
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
|
||||
await _openAssetViewer(
|
||||
resolvedAsset.asset,
|
||||
resolvedAsset.timelineService,
|
||||
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
|
||||
final notifier = _ref.read(assetViewerProvider.notifier);
|
||||
notifier.setViewerTransitionInProgress(true);
|
||||
try {
|
||||
prepareAssetViewerState(notifier, asset);
|
||||
if (viewIntentFilePath != null) {
|
||||
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
|
||||
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
|
||||
} else {
|
||||
_ref.read(viewIntentFilePathProvider.notifier).clear();
|
||||
unawaited(_viewIntentService.cleanupManagedTempFile());
|
||||
}
|
||||
|
||||
// Mirror the home-screen widget pattern: replace the route stack so
|
||||
// the viewer sits directly on top of the main timeline. Back-press
|
||||
// from the viewer lands the user on the timeline rather than on
|
||||
// whatever route happened to be current (e.g. splash, login).
|
||||
await _router.replaceAll([
|
||||
const TabShellRoute(),
|
||||
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
|
||||
]);
|
||||
} finally {
|
||||
notifier.setViewerTransitionInProgress(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
|
||||
class StubViewIntentHandler implements ViewIntentHandler {
|
||||
const StubViewIntentHandler();
|
||||
|
||||
@override
|
||||
void init() {}
|
||||
|
||||
@override
|
||||
Future<void> onAppResumed() async {}
|
||||
|
||||
@override
|
||||
Future<void> flushDeferredViewIntent() async {}
|
||||
|
||||
@override
|
||||
Future<void> handle(ViewIntentPayload attachment) async {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
final viewIntentMainTimelineReadyProvider = NotifierProvider<ViewIntentMainTimelineReadyNotifier, bool>(
|
||||
ViewIntentMainTimelineReadyNotifier.new,
|
||||
);
|
||||
|
||||
class ViewIntentMainTimelineReadyNotifier extends Notifier<bool> {
|
||||
Completer<void>? _readyCompleter;
|
||||
bool _hasSeenMainTimeline = false;
|
||||
bool _hasTimelineUsers = false;
|
||||
bool _isTimelineReady = false;
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
_readyCompleter ??= Completer<void>();
|
||||
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull;
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
final timelineStatus = ref.watch(timelineStatusProvider(timelineService)).valueOrNull ?? timelineService.status;
|
||||
|
||||
_hasTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty;
|
||||
_isTimelineReady = timelineStatus == TimelineStatus.ready;
|
||||
|
||||
final isReady = _computeReady();
|
||||
_completeWaitersIfReady(isReady);
|
||||
return isReady;
|
||||
}
|
||||
|
||||
Future<void> wait({required Duration timeout}) {
|
||||
if (state) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return _readyCompleter!.future.timeout(timeout);
|
||||
}
|
||||
|
||||
void markMountedOnce() {
|
||||
_hasSeenMainTimeline = true;
|
||||
final isReady = _computeReady();
|
||||
state = isReady;
|
||||
_completeWaitersIfReady(isReady);
|
||||
}
|
||||
|
||||
bool _computeReady() => _hasSeenMainTimeline && _hasTimelineUsers && _isTimelineReady;
|
||||
|
||||
void _completeWaitersIfReady(bool isReady) {
|
||||
if (isReady) {
|
||||
if (!(_readyCompleter?.isCompleted ?? true)) {
|
||||
_readyCompleter?.complete();
|
||||
}
|
||||
} else if (_readyCompleter?.isCompleted ?? true) {
|
||||
_readyCompleter = Completer<void>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
|
||||
final viewIntentNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
|
||||
|
||||
final viewIntentPendingProvider = NotifierProvider<ViewIntentPendingNotifier, ViewIntentPayload?>(
|
||||
ViewIntentPendingNotifier.new,
|
||||
);
|
||||
|
||||
class ViewIntentPendingNotifier extends Notifier<ViewIntentPayload?> {
|
||||
static const _ttl = Duration(minutes: 10);
|
||||
|
||||
DateTime? _deferredAt;
|
||||
|
||||
@override
|
||||
ViewIntentPayload? build() => null;
|
||||
|
||||
void defer(ViewIntentPayload attachment) {
|
||||
_deferredAt = ref.read(viewIntentNowProvider)();
|
||||
state = attachment;
|
||||
}
|
||||
|
||||
ViewIntentPayload? takeIfFresh() {
|
||||
final attachment = state;
|
||||
final deferredAt = _deferredAt;
|
||||
state = null;
|
||||
_deferredAt = null;
|
||||
|
||||
if (attachment == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class ForegroundUploadService {
|
||||
List<File> files, {
|
||||
Completer<void>? cancelToken,
|
||||
void Function(String fileId, int bytes, int totalBytes)? onProgress,
|
||||
void Function(String fileId)? onSuccess,
|
||||
void Function(String fileId, String remoteAssetId)? onSuccess,
|
||||
void Function(String fileId, String errorMessage)? onError,
|
||||
}) async {
|
||||
if (files.isEmpty) {
|
||||
@@ -171,7 +171,7 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess) {
|
||||
onSuccess?.call(fileId);
|
||||
onSuccess?.call(fileId, result.remoteAssetId!);
|
||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||
onError?.call(fileId, result.errorMessage!);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
|
||||
|
||||
class ViewIntentService {
|
||||
final ViewIntentHostApi _viewIntentHostApi;
|
||||
final Future<Directory> Function() _temporaryDirectory;
|
||||
String? _managedTempFilePath;
|
||||
|
||||
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
|
||||
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
|
||||
|
||||
Future<ViewIntentPayload?> consumeViewIntent() async {
|
||||
try {
|
||||
return await _viewIntentHostApi.consumeViewIntent();
|
||||
} catch (_) {
|
||||
// Ignore errors - view intent might not be present
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setManagedTempFilePath(String path) async {
|
||||
final previous = _managedTempFilePath;
|
||||
if (previous == path) {
|
||||
return;
|
||||
}
|
||||
_managedTempFilePath = path;
|
||||
if (previous != null) {
|
||||
await cleanupTempFile(previous);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cleanupManagedTempFile() async {
|
||||
final path = _managedTempFilePath;
|
||||
_managedTempFilePath = null;
|
||||
if (path != null) {
|
||||
await cleanupTempFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
|
||||
if (_managedTempFilePath == path) {
|
||||
_managedTempFilePath = null;
|
||||
}
|
||||
await cleanupTempFile(path);
|
||||
}
|
||||
|
||||
Future<void> cleanupTempFile(String path) async {
|
||||
if (!_isManagedTempFile(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cleanupStaleTempFiles() async {
|
||||
try {
|
||||
final tempDirectory = await _temporaryDirectory();
|
||||
await for (final entity in tempDirectory.list()) {
|
||||
if (entity is! File) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final path = entity.path;
|
||||
if (!_isManagedTempFile(path) || path == _managedTempFilePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await entity.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
bool _isManagedTempFile(String path) {
|
||||
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class ViewIntentResolvedAsset {
|
||||
final BaseAsset asset;
|
||||
final TimelineService timelineService;
|
||||
|
||||
/// Path to the materialized temp file backing this asset, if any. Set only
|
||||
/// for the transient deep-link case (no DB-backed local asset). The upload
|
||||
/// flow reads this to know which file to upload.
|
||||
final String? viewIntentFilePath;
|
||||
|
||||
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
|
||||
}
|
||||
|
||||
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
|
||||
(ref) => ViewIntentAssetResolver(
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
timelineFactory: ref.read(timelineFactoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
/// Resolves an incoming ACTION_VIEW intent into the data the asset viewer
|
||||
/// needs: a [BaseAsset] and a [TimelineService] containing it.
|
||||
///
|
||||
/// Always wraps the resolved asset in a 1-element [TimelineOrigin.deepLink]
|
||||
/// timeline — mirroring how the app's home-screen widgets open a single
|
||||
/// asset. We don't try to map the asset to its position in the user's main
|
||||
/// timeline because that would require ROW_NUMBER queries over the full
|
||||
/// merged timeline (slow at scale) and complex "wait until the main timeline
|
||||
/// service is ready at that index" coordination. Back-navigation from the
|
||||
/// viewer lands on the main timeline because the handler pushes the viewer
|
||||
/// on top of [TabShellRoute].
|
||||
class ViewIntentAssetResolver {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final TimelineFactory _timelineFactory;
|
||||
static final Logger _logger = Logger('ViewIntentAssetResolver');
|
||||
|
||||
const ViewIntentAssetResolver({
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required TimelineFactory timelineFactory,
|
||||
}) : _localAssetRepository = localAssetRepository,
|
||||
_timelineFactory = timelineFactory;
|
||||
|
||||
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
|
||||
final localAssetId = attachment.localAssetId;
|
||||
final path = attachment.path;
|
||||
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
|
||||
|
||||
if (localAssetId == null && path == null) {
|
||||
throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.');
|
||||
}
|
||||
|
||||
// Prefer the DB-backed local asset when we have one — it carries richer
|
||||
// metadata than the transient model we'd otherwise synthesise.
|
||||
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
|
||||
final asset = localAsset ?? _toTransientAsset(attachment);
|
||||
|
||||
return ViewIntentResolvedAsset(
|
||||
asset: asset,
|
||||
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
|
||||
// viewIntentFilePath is only meaningful for the transient case — the
|
||||
// DB-backed local asset carries its own path/URI for the upload flow.
|
||||
viewIntentFilePath: localAsset == null ? path : null,
|
||||
);
|
||||
}
|
||||
|
||||
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
|
||||
final now = DateTime.now();
|
||||
return LocalAsset(
|
||||
// TODO(Ombodi): Introduce a file-backed BaseAsset for path-only view intents.
|
||||
// The viewer currently expects a BaseAsset, so this temporary LocalAsset
|
||||
// adapts an unmanaged file into the existing timeline/viewer pipeline.
|
||||
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
|
||||
name: attachment.fileName,
|
||||
type: attachment.isVideo ? AssetType.video : AssetType.image,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isEdited: false,
|
||||
playbackStyle: attachment.playbackStyle,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -182,9 +183,11 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
Future<void> handleSyncFlow() async {
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||
|
||||
await backgroundManager.syncLocal(full: true);
|
||||
await backgroundManager.syncRemote();
|
||||
await viewIntentHandler.flushDeferredViewIntent();
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||
@@ -259,7 +262,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -346,7 +349,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
|
||||
class LanguageSettings extends HookConsumerWidget {
|
||||
@@ -85,7 +84,7 @@ class LanguageSettings extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: filteredLocaleEntries.value.length,
|
||||
itemExtent: 64.0,
|
||||
scrollCacheExtent: const .pixels(100),
|
||||
cacheExtent: 100,
|
||||
itemBuilder: (context, index) {
|
||||
final countryName = filteredLocaleEntries.value[index].key;
|
||||
final localeValue = filteredLocaleEntries.value[index].value;
|
||||
|
||||
@@ -36,6 +36,10 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
handleReorder(int oldIndex, int newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
final entry = entries.value.removeAt(oldIndex);
|
||||
entries.value.insert(newIndex, entry);
|
||||
entries.value = [...entries.value];
|
||||
@@ -109,7 +113,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: entries.value.length,
|
||||
onReorderItem: handleReorder,
|
||||
onReorder: handleReorder,
|
||||
itemBuilder: (context, index) {
|
||||
return EndpointInput(
|
||||
key: Key(index.toString()),
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ run = [
|
||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||
"dart run pigeon --input pigeon/network_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
|
||||
"dart run pigeon --input pigeon/view_intent_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."codegen:translation"]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+12
-12
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "96.0.0"
|
||||
version: "93.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
version: "10.0.1"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1088,10 +1088,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1719,10 +1719,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.10"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1775,10 +1775,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
version: "6.3.29"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1996,5 +1996,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: "3.44.0"
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: "3.41.9"
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ publish_to: 'none'
|
||||
version: 3.0.0+3047
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
flutter: 3.44.0
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
flutter: 3.41.9
|
||||
|
||||
dependencies:
|
||||
async: ^2.13.1
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
||||
|
||||
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
|
||||
|
||||
class MockAppRouter extends Mock implements AppRouter {}
|
||||
|
||||
class MockAuthService extends Mock implements AuthService {}
|
||||
|
||||
class MockApiService extends Mock implements ApiService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
class MockSecureStorageService extends Mock implements SecureStorageService {}
|
||||
|
||||
class MockWidgetService extends Mock implements WidgetService {}
|
||||
|
||||
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
|
||||
|
||||
class FakeTimelineService extends Fake implements TimelineService {}
|
||||
|
||||
class TestViewIntentService extends ViewIntentService {
|
||||
ViewIntentPayload? consumedAttachment;
|
||||
int cleanupStaleTempFilesCalls = 0;
|
||||
int cleanupManagedTempFileCalls = 0;
|
||||
final List<String> managedTempPaths = [];
|
||||
|
||||
TestViewIntentService() : super(MockViewIntentHostApi());
|
||||
|
||||
@override
|
||||
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
|
||||
|
||||
@override
|
||||
Future<void> cleanupStaleTempFiles() async {
|
||||
cleanupStaleTempFilesCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cleanupManagedTempFile() async {
|
||||
cleanupManagedTempFileCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setManagedTempFilePath(String path) async {
|
||||
managedTempPaths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
class TestAuthNotifier extends AuthNotifier {
|
||||
TestAuthNotifier(Ref ref, AuthState initial)
|
||||
: super(
|
||||
MockAuthService(),
|
||||
MockApiService(),
|
||||
MockUserService(),
|
||||
MockSecureStorageService(),
|
||||
MockWidgetService(),
|
||||
ref,
|
||||
) {
|
||||
state = initial;
|
||||
}
|
||||
|
||||
void setAuthenticated(bool isAuthenticated) {
|
||||
state = state.copyWith(isAuthenticated: isAuthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late TestViewIntentService viewIntentService;
|
||||
late MockViewIntentAssetResolver resolver;
|
||||
late MockAppRouter router;
|
||||
late TestAuthNotifier authNotifier;
|
||||
late ProviderContainer container;
|
||||
late AndroidViewIntentHandler handler;
|
||||
late ViewIntentPayload payload;
|
||||
late LocalAsset deepLinkAsset;
|
||||
late TimelineService deepLinkTimelineService;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakePageRouteInfo());
|
||||
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
|
||||
registerFallbackValue(FakeTimelineService());
|
||||
registerFallbackValue(
|
||||
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
viewIntentService = TestViewIntentService();
|
||||
resolver = MockViewIntentAssetResolver();
|
||||
router = MockAppRouter();
|
||||
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
|
||||
deepLinkAsset = _localAsset(id: 'local-1');
|
||||
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
|
||||
|
||||
when(() => router.replaceAll(any())).thenAnswer((_) async {});
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
viewIntentServiceProvider.overrideWithValue(viewIntentService),
|
||||
viewIntentAssetResolverProvider.overrideWithValue(resolver),
|
||||
appRouterProvider.overrideWithValue(router),
|
||||
// viewIntentMainTimelineReadyProvider reads both of these to compute
|
||||
// its ready state — without them wait() never resolves.
|
||||
timelineServiceProvider.overrideWithValue(deepLinkTimelineService),
|
||||
timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])),
|
||||
authProvider.overrideWith((ref) {
|
||||
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
|
||||
return authNotifier;
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
|
||||
await container.read(timelineUsersProvider.future);
|
||||
handler = container.read(_handlerProvider);
|
||||
|
||||
addTearDown(() async {
|
||||
await deepLinkTimelineService.dispose();
|
||||
container.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('handle defers unauthenticated attachment', () async {
|
||||
authNotifier.setAuthenticated(false);
|
||||
|
||||
await handler.handle(payload);
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), payload);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('flushDeferredViewIntent waits for main timeline readiness before flushing pending attachment', (
|
||||
tester,
|
||||
) async {
|
||||
authNotifier.setAuthenticated(false);
|
||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||
authNotifier.setAuthenticated(true);
|
||||
|
||||
when(() => resolver.resolve(payload)).thenAnswer((_) async {
|
||||
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
|
||||
});
|
||||
|
||||
unawaited(handler.flushDeferredViewIntent());
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), payload);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
|
||||
container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.idle();
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), isNull);
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
});
|
||||
|
||||
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
|
||||
await handler.flushDeferredViewIntent();
|
||||
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed cleans stale temp files when no attachment is present', () async {
|
||||
viewIntentService.consumedAttachment = null;
|
||||
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
|
||||
viewIntentService.consumedAttachment = null;
|
||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
|
||||
viewIntentService.consumedAttachment = payload;
|
||||
when(() => resolver.resolve(payload)).thenAnswer(
|
||||
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
|
||||
);
|
||||
|
||||
unawaited(handler.onAppResumed());
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.idle();
|
||||
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
|
||||
// main timeline — mirrors the home-screen widget navigation pattern.
|
||||
final captured = verify(() => router.replaceAll(captureAny())).captured;
|
||||
expect(captured, hasLength(1));
|
||||
final routes = captured.single as List<PageRouteInfo<dynamic>>;
|
||||
expect(routes, hasLength(2));
|
||||
expect(routes[0].routeName, TabShellRoute.name);
|
||||
expect(routes[1].routeName, AssetViewerRoute.name);
|
||||
});
|
||||
}
|
||||
|
||||
AuthState _authState({required bool isAuthenticated}) {
|
||||
return AuthState(
|
||||
deviceId: 'device-1',
|
||||
userId: 'user-1',
|
||||
userEmail: 'user@example.com',
|
||||
isAuthenticated: isAuthenticated,
|
||||
name: 'User',
|
||||
isAdmin: false,
|
||||
profileImagePath: '',
|
||||
);
|
||||
}
|
||||
|
||||
LocalAsset _localAsset({required String id}) {
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: 'checksum-1',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: origin,
|
||||
));
|
||||
}
|
||||
|
||||
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
|
||||
final timelineService = _timelineServiceFromAssets(assets, origin);
|
||||
|
||||
if (!timelineService.isReady) {
|
||||
await timelineService.watchStatus().firstWhere((status) => status == TimelineStatus.ready);
|
||||
}
|
||||
|
||||
return timelineService;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
||||
|
||||
void main() {
|
||||
late DateTime now;
|
||||
late ProviderContainer container;
|
||||
|
||||
final attachment = ViewIntentPayload(
|
||||
path: '/tmp/file.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '42',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
now = DateTime(2026, 4, 17, 12);
|
||||
container = ProviderContainer(
|
||||
overrides: [viewIntentNowProvider.overrideWithValue(() => now)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
});
|
||||
|
||||
test('defer stores pending attachment', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), attachment);
|
||||
});
|
||||
|
||||
test('takeIfFresh returns pending attachment once', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
|
||||
final first = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
final second = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(first, attachment);
|
||||
expect(second, isNull);
|
||||
});
|
||||
|
||||
test('takeIfFresh drops expired attachment', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
now = now.add(const Duration(minutes: 11));
|
||||
|
||||
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(result, isNull);
|
||||
expect(container.read(viewIntentPendingProvider), isNull);
|
||||
});
|
||||
|
||||
test('newer deferred attachment replaces older one', () {
|
||||
final newerAttachment = ViewIntentPayload(
|
||||
path: '/tmp/file-2.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '43',
|
||||
);
|
||||
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
container.read(viewIntentPendingProvider.notifier).defer(newerAttachment);
|
||||
|
||||
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(result, newerAttachment);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
|
||||
class MockTimelineFactory extends Mock implements TimelineFactory {}
|
||||
|
||||
void main() {
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockTimelineFactory timelineFactory;
|
||||
late List<TimelineService> createdTimelineServices;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
timelineFactory = MockTimelineFactory();
|
||||
createdTimelineServices = [];
|
||||
|
||||
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
|
||||
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
|
||||
final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink);
|
||||
createdTimelineServices.add(timelineService);
|
||||
return timelineService;
|
||||
});
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
|
||||
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
|
||||
],
|
||||
);
|
||||
|
||||
addTearDown(() async {
|
||||
for (final timelineService in createdTimelineServices) {
|
||||
await timelineService.dispose();
|
||||
}
|
||||
container.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
|
||||
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
|
||||
|
||||
expect(result.asset, equals(localAsset));
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
|
||||
});
|
||||
|
||||
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
|
||||
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
|
||||
});
|
||||
|
||||
test('returns transient asset for path-only attachment', () async {
|
||||
final result = await _resolve(
|
||||
container,
|
||||
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
|
||||
);
|
||||
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
|
||||
|
||||
final asset = result.asset as LocalAsset;
|
||||
expect(asset.localId, startsWith('-'));
|
||||
expect(asset.name, 'incoming.webp');
|
||||
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
|
||||
});
|
||||
|
||||
test('throws when neither localAssetId nor path is provided', () async {
|
||||
await expectLater(
|
||||
_resolve(container, _payload(localAssetId: null, path: null)),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
|
||||
return container.read(viewIntentAssetResolverProvider).resolve(payload);
|
||||
}
|
||||
|
||||
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
|
||||
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
|
||||
}
|
||||
|
||||
LocalAsset _localAsset({required String id, String? checksum}) {
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: origin,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
||||
|
||||
void main() {
|
||||
late MockViewIntentHostApi hostApi;
|
||||
late ViewIntentService service;
|
||||
late Directory tempRoot;
|
||||
late Directory cacheDir;
|
||||
|
||||
final attachment = ViewIntentPayload(
|
||||
path: '/tmp/file.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '42',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
hostApi = MockViewIntentHostApi();
|
||||
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
|
||||
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
|
||||
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
clearInteractions(hostApi);
|
||||
if (await tempRoot.exists()) {
|
||||
await tempRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeViewIntent returns null when no attachment', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('consumeViewIntent returns attachment when present', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, attachment);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('consumeViewIntent swallows host api errors', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('setManagedTempFilePath cleans previous managed temp file', () async {
|
||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
||||
|
||||
await service.setManagedTempFilePath(firstFile.path);
|
||||
await service.setManagedTempFilePath(secondFile.path);
|
||||
|
||||
expect(await firstFile.exists(), isFalse);
|
||||
expect(await secondFile.exists(), isTrue);
|
||||
|
||||
await service.cleanupManagedTempFile();
|
||||
expect(await secondFile.exists(), isFalse);
|
||||
});
|
||||
|
||||
test('cleanupTempFile ignores non-managed paths', () async {
|
||||
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
|
||||
|
||||
await service.cleanupTempFile(nonManagedFile.path);
|
||||
|
||||
expect(await nonManagedFile.exists(), isTrue);
|
||||
});
|
||||
|
||||
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
|
||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
||||
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
|
||||
|
||||
await service.cleanupStaleTempFiles();
|
||||
|
||||
expect(await firstFile.exists(), isFalse);
|
||||
expect(await secondFile.exists(), isFalse);
|
||||
expect(await unrelatedFile.exists(), isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user