mirror of
https://github.com/immich-app/immich.git
synced 2026-05-24 08:32:28 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faaeaace1d | |||
| 2837de2029 | |||
| eb27635f22 |
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
|
|||||||
force: false,
|
force: false,
|
||||||
ids: [assetToTrash.id],
|
ids: [assetToTrash.id],
|
||||||
});
|
});
|
||||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
await page.keyboard.press('Escape');
|
||||||
await page.getByText('Trash', { exact: true }).click();
|
await page.getByText('Trash', { exact: true }).click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||||
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
|
|||||||
ids: [assetToArchive.id],
|
ids: [assetToArchive.id],
|
||||||
});
|
});
|
||||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
await page.keyboard.press('Escape');
|
||||||
await page.getByRole('link').getByText('Archive').click();
|
await page.getByRole('link').getByText('Archive').click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
|
|||||||
});
|
});
|
||||||
// ensure thumbnail still exists and has favorite icon
|
// ensure thumbnail still exists and has favorite icon
|
||||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
await page.keyboard.press('Escape');
|
||||||
await page.getByRole('link').getByText('Favorites').click();
|
await page.getByRole('link').getByText('Favorites').click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
||||||
|
|||||||
@@ -89,20 +89,6 @@
|
|||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Allow Immich to act as an image viewer -->
|
|
||||||
<intent-filter android:label="View in Immich">
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:scheme="content" android:mimeType="image/*" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Allow Immich to act as a video viewer -->
|
|
||||||
<intent-filter android:label="View in Immich">
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:scheme="content" android:mimeType="video/*" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- immich:// URL scheme handling -->
|
<!-- immich:// URL scheme handling -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ext.SdkExtensions
|
import android.os.ext.SdkExtensions
|
||||||
import app.alextran.immich.background.BackgroundEngineLock
|
import app.alextran.immich.background.BackgroundEngineLock
|
||||||
@@ -23,7 +22,6 @@ import app.alextran.immich.permission.PermissionApiImpl
|
|||||||
import app.alextran.immich.sync.NativeSyncApi
|
import app.alextran.immich.sync.NativeSyncApi
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||||
import app.alextran.immich.viewintent.ViewIntentPlugin
|
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
@@ -33,11 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
registerPlugins(this, flutterEngine)
|
registerPlugins(this, flutterEngine)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
setIntent(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
HttpClientManager.initialize(ctx)
|
HttpClientManager.initialize(ctx)
|
||||||
@@ -62,7 +55,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||||
|
|
||||||
flutterEngine.plugins.add(ViewIntentPlugin())
|
|
||||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||||
flutterEngine.plugins.add(permissionApiImpl)
|
flutterEngine.plugins.add(permissionApiImpl)
|
||||||
|
|||||||
-292
@@ -1,292 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-201
@@ -1,201 +0,0 @@
|
|||||||
package app.alextran.immich.viewintent
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.DocumentsContract
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.util.Log
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
|
||||||
import io.flutter.plugin.common.PluginRegistry
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
private const val TAG = "ViewIntentPlugin"
|
|
||||||
|
|
||||||
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
|
|
||||||
private var context: Context? = null
|
|
||||||
private var activity: Activity? = null
|
|
||||||
private var unconsumedIntent: Intent? = null
|
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
context = binding.applicationContext
|
|
||||||
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
|
|
||||||
ioScope.cancel()
|
|
||||||
context = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
|
||||||
activity = binding.activity
|
|
||||||
unconsumedIntent = binding.activity.intent
|
|
||||||
binding.addOnNewIntentListener(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
|
||||||
activity = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
|
||||||
onAttachedToActivity(binding)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
|
||||||
activity = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent): Boolean {
|
|
||||||
unconsumedIntent = intent
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
|
|
||||||
val context = context ?: run {
|
|
||||||
callback(Result.success(null))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val intent = unconsumedIntent ?: activity?.intent
|
|
||||||
|
|
||||||
if (intent?.action != Intent.ACTION_VIEW) {
|
|
||||||
callback(Result.success(null))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = intent.data
|
|
||||||
if (uri == null) {
|
|
||||||
callback(Result.success(null))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ioScope.launch {
|
|
||||||
try {
|
|
||||||
val mimeType = context.contentResolver.getType(uri) ?: intent.type
|
|
||||||
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
|
|
||||||
callback(Result.success(null))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val localAssetId = extractLocalAssetId(context, uri, mimeType)
|
|
||||||
val tempFilePath = if (localAssetId == null) {
|
|
||||||
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
|
|
||||||
callback(Result.success(null))
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val payload = ViewIntentPayload(
|
|
||||||
path = tempFilePath,
|
|
||||||
mimeType = mimeType,
|
|
||||||
localAssetId = localAssetId,
|
|
||||||
)
|
|
||||||
consumeViewIntent(intent)
|
|
||||||
callback(Result.success(payload))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
callback(Result.failure(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun consumeViewIntent(currentIntent: Intent) {
|
|
||||||
unconsumedIntent = Intent(currentIntent).apply {
|
|
||||||
action = null
|
|
||||||
data = null
|
|
||||||
type = null
|
|
||||||
}
|
|
||||||
activity?.intent = unconsumedIntent
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
|
|
||||||
return tryExtractDocumentLocalAssetId(context, uri)
|
|
||||||
?: tryParseContentUriId(uri)
|
|
||||||
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
|
|
||||||
return try {
|
|
||||||
if (!DocumentsContract.isDocumentUri(context, uri)) return null
|
|
||||||
val docId = DocumentsContract.getDocumentId(uri)
|
|
||||||
if (docId.isBlank() || docId.startsWith("raw:")) return null
|
|
||||||
docId.substringAfter(':', docId).toLongOrNull()?.toString()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tryParseContentUriId(uri: Uri): String? {
|
|
||||||
val id = uri.lastPathSegment?.toLongOrNull() ?: return null
|
|
||||||
return if (id >= 0) id.toString() else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
|
|
||||||
return try {
|
|
||||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
|
|
||||||
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
|
||||||
FileOutputStream(tempFile).use { outputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
} ?: return null
|
|
||||||
tempFile
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
|
|
||||||
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
|
||||||
val (displayName, size) =
|
|
||||||
try {
|
|
||||||
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) return null
|
|
||||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
||||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
|
||||||
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
|
|
||||||
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
|
|
||||||
if (name.isNullOrBlank() || bytes < 0) return null
|
|
||||||
name to bytes
|
|
||||||
} ?: return null
|
|
||||||
} catch (_: Exception) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val tableUri = when {
|
|
||||||
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
||||||
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
context.contentResolver
|
|
||||||
.query(
|
|
||||||
tableUri,
|
|
||||||
arrayOf(MediaStore.MediaColumns._ID),
|
|
||||||
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
|
|
||||||
arrayOf(displayName, size.toString()),
|
|
||||||
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
|
|
||||||
)?.use { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) return null
|
|
||||||
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
|
||||||
if (idIndex < 0) return null
|
|
||||||
cursor.getLong(idIndex).toString()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
|
|||||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
@@ -129,7 +128,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||||||
case AppLifecycleState.resumed:
|
case AppLifecycleState.resumed:
|
||||||
dPrint(() => "[APP STATE] resumed");
|
dPrint(() => "[APP STATE] resumed");
|
||||||
ref.read(appStateProvider.notifier).handleAppResume();
|
ref.read(appStateProvider.notifier).handleAppResume();
|
||||||
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
|
||||||
break;
|
break;
|
||||||
case AppLifecycleState.inactive:
|
case AppLifecycleState.inactive:
|
||||||
dPrint(() => "[APP STATE] inactive");
|
dPrint(() => "[APP STATE] inactive");
|
||||||
@@ -235,7 +233,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.read(viewIntentHandlerProvider).init();
|
|
||||||
ref.read(shareIntentUploadProvider.notifier).init();
|
ref.read(shareIntentUploadProvider.notifier).init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
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,7 +17,6 @@ import 'package:immich_mobile/providers/auth.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||||
@@ -315,7 +314,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
|||||||
final wsProvider = ref.read(websocketProvider.notifier);
|
final wsProvider = ref.read(websocketProvider.notifier);
|
||||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||||
@@ -330,8 +328,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
|||||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await viewIntentHandler.flushDeferredViewIntent();
|
|
||||||
|
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
backgroundManager.hashAssets().then((_) {
|
backgroundManager.hashAssets().then((_) {
|
||||||
|
|||||||
+35
-35
@@ -9,22 +9,14 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
Object? _extractReplyValueOrThrow(
|
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||||
List<Object?>? replyList,
|
|
||||||
String channelName, {
|
|
||||||
required bool isNullValid,
|
|
||||||
}) {
|
|
||||||
if (replyList == null) {
|
if (replyList == null) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
code: 'channel-error',
|
code: 'channel-error',
|
||||||
message: 'Unable to establish connection on channel: "$channelName".',
|
message: 'Unable to establish connection on channel: "$channelName".',
|
||||||
);
|
);
|
||||||
} else if (replyList.length > 1) {
|
} else if (replyList.length > 1) {
|
||||||
throw PlatformException(
|
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||||
code: replyList[0]! as String,
|
|
||||||
message: replyList[1] as String?,
|
|
||||||
details: replyList[2],
|
|
||||||
);
|
|
||||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
code: 'null-error',
|
code: 'null-error',
|
||||||
@@ -34,8 +26,6 @@ Object? _extractReplyValueOrThrow(
|
|||||||
return replyList.firstOrNull;
|
return replyList.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -62,35 +52,50 @@ class LocalImageApi {
|
|||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
final String pigeonVar_messageChannelSuffix;
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
Future<Map<String, int>?> requestImage(String assetId, {required int requestId, required int width, required int height, required bool isVideo, required bool preferEncoded, }) async {
|
Future<Map<String, int>?> requestImage(
|
||||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
String assetId, {
|
||||||
|
required int requestId,
|
||||||
|
required int width,
|
||||||
|
required int height,
|
||||||
|
required bool isVideo,
|
||||||
|
required bool preferEncoded,
|
||||||
|
}) async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, requestId, width, height, isVideo, preferEncoded]);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
|
||||||
|
assetId,
|
||||||
|
requestId,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
isVideo,
|
||||||
|
preferEncoded,
|
||||||
|
]);
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue as Map<Object?, Object?>?)?.cast<String, int>();
|
return (pigeonVar_replyValue as Map<Object?, Object?>?)?.cast<String, int>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelRequest(int requestId) async {
|
Future<void> cancelRequest(int requestId) async {
|
||||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -99,16 +104,12 @@ class LocalImageApi {
|
|||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||||
pigeonVar_replyList,
|
|
||||||
pigeonVar_channelName,
|
|
||||||
isNullValid: true,
|
|
||||||
)
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, int>> getThumbhash(String thumbhash) async {
|
Future<Map<String, int>> getThumbhash(String thumbhash) async {
|
||||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -118,11 +119,10 @@ class LocalImageApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, int>();
|
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, int>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-170
@@ -9,22 +9,14 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
Object? _extractReplyValueOrThrow(
|
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||||
List<Object?>? replyList,
|
|
||||||
String channelName, {
|
|
||||||
required bool isNullValid,
|
|
||||||
}) {
|
|
||||||
if (replyList == null) {
|
if (replyList == null) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
code: 'channel-error',
|
code: 'channel-error',
|
||||||
message: 'Unable to establish connection on channel: "$channelName".',
|
message: 'Unable to establish connection on channel: "$channelName".',
|
||||||
);
|
);
|
||||||
} else if (replyList.length > 1) {
|
} else if (replyList.length > 1) {
|
||||||
throw PlatformException(
|
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||||
code: replyList[0]! as String,
|
|
||||||
message: replyList[1] as String?,
|
|
||||||
details: replyList[2],
|
|
||||||
);
|
|
||||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
code: 'null-error',
|
code: 'null-error',
|
||||||
@@ -45,9 +37,7 @@ bool _deepEquals(Object? a, Object? b) {
|
|||||||
return a == b;
|
return a == b;
|
||||||
}
|
}
|
||||||
if (a is List && b is List) {
|
if (a is List && b is List) {
|
||||||
return a.length == b.length &&
|
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
a.indexed
|
|
||||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
|
||||||
}
|
}
|
||||||
if (a is Map && b is Map) {
|
if (a is Map && b is Map) {
|
||||||
if (a.length != b.length) {
|
if (a.length != b.length) {
|
||||||
@@ -96,15 +86,7 @@ int _deepHash(Object? value) {
|
|||||||
return value.hashCode;
|
return value.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||||
enum PlatformAssetPlaybackStyle {
|
|
||||||
unknown,
|
|
||||||
image,
|
|
||||||
video,
|
|
||||||
imageAnimated,
|
|
||||||
livePhoto,
|
|
||||||
videoLooping,
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
PlatformAsset({
|
PlatformAsset({
|
||||||
@@ -172,7 +154,8 @@ class PlatformAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList(); }
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
static PlatformAsset decode(Object result) {
|
static PlatformAsset decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -203,7 +186,20 @@ class PlatformAsset {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
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
|
@override
|
||||||
@@ -231,17 +227,12 @@ class PlatformAlbum {
|
|||||||
int assetCount;
|
int assetCount;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[
|
return <Object?>[id, name, updatedAt, isCloud, assetCount];
|
||||||
id,
|
|
||||||
name,
|
|
||||||
updatedAt,
|
|
||||||
isCloud,
|
|
||||||
assetCount,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList(); }
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
static PlatformAlbum decode(Object result) {
|
static PlatformAlbum decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -263,7 +254,11 @@ class PlatformAlbum {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
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
|
@override
|
||||||
@@ -272,12 +267,7 @@ class PlatformAlbum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SyncDelta {
|
class SyncDelta {
|
||||||
SyncDelta({
|
SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums});
|
||||||
required this.hasChanges,
|
|
||||||
required this.updates,
|
|
||||||
required this.deletes,
|
|
||||||
required this.assetAlbums,
|
|
||||||
});
|
|
||||||
|
|
||||||
bool hasChanges;
|
bool hasChanges;
|
||||||
|
|
||||||
@@ -288,16 +278,12 @@ class SyncDelta {
|
|||||||
Map<String, List<String>> assetAlbums;
|
Map<String, List<String>> assetAlbums;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[
|
return <Object?>[hasChanges, updates, deletes, assetAlbums];
|
||||||
hasChanges,
|
|
||||||
updates,
|
|
||||||
deletes,
|
|
||||||
assetAlbums,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList(); }
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
static SyncDelta decode(Object result) {
|
static SyncDelta decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -318,7 +304,10 @@ class SyncDelta {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
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
|
@override
|
||||||
@@ -327,11 +316,7 @@ class SyncDelta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class HashResult {
|
class HashResult {
|
||||||
HashResult({
|
HashResult({required this.assetId, this.error, this.hash});
|
||||||
required this.assetId,
|
|
||||||
this.error,
|
|
||||||
this.hash,
|
|
||||||
});
|
|
||||||
|
|
||||||
String assetId;
|
String assetId;
|
||||||
|
|
||||||
@@ -340,23 +325,16 @@ class HashResult {
|
|||||||
String? hash;
|
String? hash;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[
|
return <Object?>[assetId, error, hash];
|
||||||
assetId,
|
|
||||||
error,
|
|
||||||
hash,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList(); }
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
static HashResult decode(Object result) {
|
static HashResult decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
return HashResult(
|
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
|
||||||
assetId: result[0]! as String,
|
|
||||||
error: result[1] as String?,
|
|
||||||
hash: result[2] as String?,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -377,11 +355,7 @@ class HashResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CloudIdResult {
|
class CloudIdResult {
|
||||||
CloudIdResult({
|
CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||||
required this.assetId,
|
|
||||||
this.error,
|
|
||||||
this.cloudId,
|
|
||||||
});
|
|
||||||
|
|
||||||
String assetId;
|
String assetId;
|
||||||
|
|
||||||
@@ -390,23 +364,16 @@ class CloudIdResult {
|
|||||||
String? cloudId;
|
String? cloudId;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[
|
return <Object?>[assetId, error, cloudId];
|
||||||
assetId,
|
|
||||||
error,
|
|
||||||
cloudId,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList(); }
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
static CloudIdResult decode(Object result) {
|
static CloudIdResult decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
return CloudIdResult(
|
return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?);
|
||||||
assetId: result[0]! as String,
|
|
||||||
error: result[1] as String?,
|
|
||||||
cloudId: result[2] as String?,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -418,7 +385,9 @@ class CloudIdResult {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
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
|
@override
|
||||||
@@ -426,7 +395,6 @@ class CloudIdResult {
|
|||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -434,22 +402,22 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
} else if (value is PlatformAssetPlaybackStyle) {
|
} else if (value is PlatformAssetPlaybackStyle) {
|
||||||
buffer.putUint8(129);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.index);
|
writeValue(buffer, value.index);
|
||||||
} else if (value is PlatformAsset) {
|
} else if (value is PlatformAsset) {
|
||||||
buffer.putUint8(130);
|
buffer.putUint8(130);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is PlatformAlbum) {
|
} else if (value is PlatformAlbum) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SyncDelta) {
|
} else if (value is SyncDelta) {
|
||||||
buffer.putUint8(132);
|
buffer.putUint8(132);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is HashResult) {
|
} else if (value is HashResult) {
|
||||||
buffer.putUint8(133);
|
buffer.putUint8(133);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is CloudIdResult) {
|
} else if (value is CloudIdResult) {
|
||||||
buffer.putUint8(134);
|
buffer.putUint8(134);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
@@ -484,8 +452,8 @@ class NativeSyncApi {
|
|||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
@@ -493,7 +461,8 @@ class NativeSyncApi {
|
|||||||
final String pigeonVar_messageChannelSuffix;
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
Future<bool> shouldFullSync() async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -503,16 +472,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return pigeonVar_replyValue! as bool;
|
return pigeonVar_replyValue! as bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SyncDelta> getMediaChanges() async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -522,16 +491,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return pigeonVar_replyValue! as SyncDelta;
|
return pigeonVar_replyValue! as SyncDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkpointSync() async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -540,16 +509,12 @@ class NativeSyncApi {
|
|||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||||
pigeonVar_replyList,
|
|
||||||
pigeonVar_channelName,
|
|
||||||
isNullValid: true,
|
|
||||||
)
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearSyncCheckpoint() async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -558,16 +523,12 @@ class NativeSyncApi {
|
|||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||||
pigeonVar_replyList,
|
|
||||||
pigeonVar_channelName,
|
|
||||||
isNullValid: true,
|
|
||||||
)
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<String>> getAssetIdsForAlbum(String albumId) async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -577,16 +538,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<PlatformAlbum>> getAlbums() async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -596,16 +557,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
|
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> getAssetsCountSince(String albumId, int timestamp) async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -615,16 +576,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return pigeonVar_replyValue! as int;
|
return pigeonVar_replyValue! as int;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<PlatformAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -634,16 +595,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
|
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -653,16 +614,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
|
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelHashing() async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -671,16 +632,12 @@ class NativeSyncApi {
|
|||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||||
pigeonVar_replyList,
|
|
||||||
pigeonVar_channelName,
|
|
||||||
isNullValid: true,
|
|
||||||
)
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -690,16 +647,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -709,16 +666,16 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return pigeonVar_replyValue! as bool;
|
return pigeonVar_replyValue! as bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
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?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -728,11 +685,10 @@ class NativeSyncApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
)
|
);
|
||||||
;
|
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-208
@@ -1,208 +0,0 @@
|
|||||||
// 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,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
@@ -11,9 +10,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
|||||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
|
||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
|
||||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
@@ -30,11 +26,7 @@ class UploadActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isTimeline = source == ActionSource.timeline;
|
final isTimeline = source == ActionSource.timeline;
|
||||||
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
|
|
||||||
List<LocalAsset>? assets;
|
List<LocalAsset>? assets;
|
||||||
var isUploadDialogOpen = false;
|
|
||||||
var wasUploadCancelled = false;
|
|
||||||
Future<void>? uploadDialogFuture;
|
|
||||||
|
|
||||||
if (source == ActionSource.timeline) {
|
if (source == ActionSource.timeline) {
|
||||||
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
||||||
@@ -43,50 +35,22 @@ class UploadActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
} else {
|
} else {
|
||||||
isUploadDialogOpen = true;
|
unawaited(
|
||||||
uploadDialogFuture =
|
showDialog(
|
||||||
showDialog<void>(
|
context: context,
|
||||||
context: context,
|
barrierDismissible: false,
|
||||||
barrierDismissible: false,
|
builder: (dialogContext) => const _UploadProgressDialog(),
|
||||||
builder: (dialogContext) => _UploadProgressDialog(
|
),
|
||||||
onCancel: () {
|
);
|
||||||
wasUploadCancelled = true;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).whenComplete(() {
|
|
||||||
isUploadDialogOpen = false;
|
|
||||||
});
|
|
||||||
unawaited(uploadDialogFuture);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var success = false;
|
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||||
if (!isTimeline && viewerIntentFilePath != null) {
|
|
||||||
final viewIntentService = ref.read(viewIntentServiceProvider);
|
|
||||||
viewIntentService.markUploadActive(viewerIntentFilePath);
|
|
||||||
var hasError = false;
|
|
||||||
try {
|
|
||||||
await ref
|
|
||||||
.read(foregroundUploadServiceProvider)
|
|
||||||
.uploadShareIntent(
|
|
||||||
[File(viewerIntentFilePath)],
|
|
||||||
onError: (_, _) {
|
|
||||||
hasError = true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await viewIntentService.markUploadInactive(viewerIntentFilePath);
|
|
||||||
}
|
|
||||||
success = !hasError;
|
|
||||||
} else {
|
|
||||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
|
||||||
success = result.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
if (!isTimeline && context.mounted) {
|
||||||
Navigator.of(context, rootNavigator: true).pop();
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted && !success && !wasUploadCancelled) {
|
if (context.mounted && !result.success) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||||
@@ -109,9 +73,7 @@ class UploadActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UploadProgressDialog extends ConsumerWidget {
|
class _UploadProgressDialog extends ConsumerWidget {
|
||||||
final VoidCallback onCancel;
|
const _UploadProgressDialog();
|
||||||
|
|
||||||
const _UploadProgressDialog({required this.onCancel});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -141,8 +103,7 @@ class _UploadProgressDialog extends ConsumerWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(manualUploadCancelTokenProvider)?.complete();
|
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||||
onCancel();
|
Navigator.of(context).pop();
|
||||||
Navigator.of(context, rootNavigator: true).pop();
|
|
||||||
},
|
},
|
||||||
labelText: 'cancel'.t(context: context),
|
labelText: 'cancel'.t(context: context),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
|||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/metadata.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/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
|
|
||||||
@@ -324,16 +323,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
required PhotoViewHeroAttributes? heroAttributes,
|
required PhotoViewHeroAttributes? heroAttributes,
|
||||||
required bool isCurrent,
|
required bool isCurrent,
|
||||||
required bool isPlayingMotionVideo,
|
required bool isPlayingMotionVideo,
|
||||||
required String? localFilePath,
|
|
||||||
}) {
|
}) {
|
||||||
final size = context.sizeData;
|
final size = context.sizeData;
|
||||||
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
|
|
||||||
|
|
||||||
if (asset.isImage && !isPlayingMotionVideo) {
|
if (asset.isImage && !isPlayingMotionVideo) {
|
||||||
return PhotoView(
|
return PhotoView(
|
||||||
key: Key(asset.heroTag),
|
key: Key(asset.heroTag),
|
||||||
index: widget.index,
|
index: widget.index,
|
||||||
imageProvider: imageProvider,
|
imageProvider: getFullImageProvider(asset, size: size),
|
||||||
heroAttributes: heroAttributes,
|
heroAttributes: heroAttributes,
|
||||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
@@ -380,9 +377,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
child: NativeVideoViewer(
|
child: NativeVideoViewer(
|
||||||
key: _NativeVideoViewerKey(asset.heroTag),
|
key: _NativeVideoViewerKey(asset.heroTag),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
localFilePath: localFilePath,
|
|
||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
image: Image(
|
||||||
|
image: getFullImageProvider(asset, size: size),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
|
||||||
|
|
||||||
final asset = _asset;
|
final asset = _asset;
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
@@ -422,8 +421,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_scrollController.snapPosition.snapOffset = _snapOffset;
|
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
|
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
@@ -443,7 +440,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
: null,
|
: null,
|
||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||||
localFilePath: viewIntentFilePath,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IgnorePointer(
|
IgnorePointer(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -20,7 +19,6 @@ import 'package:native_video_player/native_video_player.dart';
|
|||||||
|
|
||||||
class NativeVideoViewer extends ConsumerStatefulWidget {
|
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
final String? localFilePath;
|
|
||||||
final bool isCurrent;
|
final bool isCurrent;
|
||||||
final bool showControls;
|
final bool showControls;
|
||||||
final Widget image;
|
final Widget image;
|
||||||
@@ -28,7 +26,6 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
|
|||||||
const NativeVideoViewer({
|
const NativeVideoViewer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
this.localFilePath,
|
|
||||||
required this.image,
|
required this.image,
|
||||||
this.isCurrent = false,
|
this.isCurrent = false,
|
||||||
this.showControls = true,
|
this.showControls = true,
|
||||||
@@ -109,19 +106,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final localFilePath = widget.localFilePath;
|
|
||||||
if (localFilePath != null) {
|
|
||||||
final file = File(localFilePath);
|
|
||||||
if (!await file.exists()) {
|
|
||||||
throw Exception('No file found for the video');
|
|
||||||
}
|
|
||||||
|
|
||||||
return VideoSource.init(
|
|
||||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
|
||||||
type: VideoSourceType.file,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
final file = await StorageRepository().getFileForAsset(id);
|
final file = await StorageRepository().getFileForAsset(id);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
import 'package:async/async.dart';
|
||||||
@@ -147,17 +146,10 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageProvider getFullImageProvider(
|
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
|
||||||
BaseAsset asset, {
|
|
||||||
Size size = const Size(1080, 1920),
|
|
||||||
bool edited = true,
|
|
||||||
String? localFilePath,
|
|
||||||
}) {
|
|
||||||
// Create new provider and cache it
|
// Create new provider and cache it
|
||||||
final ImageProvider provider;
|
final ImageProvider provider;
|
||||||
if (localFilePath != null) {
|
if (_shouldUseLocalAsset(asset)) {
|
||||||
provider = FileImage(File(localFilePath));
|
|
||||||
} else if (_shouldUseLocalAsset(asset)) {
|
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'dart:io';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
|
||||||
import 'package:immich_mobile/services/share_intent_service.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:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
|||||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||||
_updateProgress(fileId, progress);
|
_updateProgress(fileId, progress);
|
||||||
},
|
},
|
||||||
onSuccess: (fileId, _) {
|
onSuccess: (fileId) {
|
||||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||||
},
|
},
|
||||||
onError: (fileId, errorMessage) {
|
onError: (fileId, errorMessage) {
|
||||||
|
|||||||
@@ -31,12 +31,11 @@ class ActionResult {
|
|||||||
final int count;
|
final int count;
|
||||||
final bool success;
|
final bool success;
|
||||||
final String? error;
|
final String? error;
|
||||||
final List<String> remoteAssetIds;
|
|
||||||
|
|
||||||
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
|
const ActionResult({required this.count, required this.success, this.error});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
|
String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
|
||||||
}
|
}
|
||||||
|
|
||||||
class ActionNotifier extends Notifier<void> {
|
class ActionNotifier extends Notifier<void> {
|
||||||
@@ -490,14 +489,10 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
|
|
||||||
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
||||||
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
|
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 progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
||||||
final cancelToken = Completer<void>();
|
final cancelToken = Completer<void>();
|
||||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||||
final remoteAssetIds = <String>[];
|
|
||||||
|
|
||||||
// Initialize progress for all assets
|
// Initialize progress for all assets
|
||||||
for (final asset in assetsToUpload) {
|
for (final asset in assetsToUpload) {
|
||||||
@@ -514,7 +509,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
progressNotifier.setProgress(localAssetId, progress);
|
progressNotifier.setProgress(localAssetId, progress);
|
||||||
},
|
},
|
||||||
onSuccess: (localAssetId, remoteAssetId) {
|
onSuccess: (localAssetId, remoteAssetId) {
|
||||||
remoteAssetIds.add(remoteAssetId);
|
|
||||||
progressNotifier.remove(localAssetId);
|
progressNotifier.remove(localAssetId);
|
||||||
},
|
},
|
||||||
onError: (localAssetId, errorMessage) {
|
onError: (localAssetId, errorMessage) {
|
||||||
@@ -522,14 +516,7 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final uploadedCount = remoteAssetIds.length;
|
return ActionResult(count: assetsToUpload.length, success: true);
|
||||||
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) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Failed manually upload assets', error, stack);
|
_logger.severe('Failed manually upload assets', error, stack);
|
||||||
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
|
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
||||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
|
||||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
class AndroidViewIntentHandler implements ViewIntentHandler {
|
|
||||||
final Ref _ref;
|
|
||||||
final ViewIntentService _viewIntentService;
|
|
||||||
final ViewIntentAssetResolver _viewIntentAssetResolver;
|
|
||||||
final AppRouter _router;
|
|
||||||
static final Logger _logger = Logger('ViewIntentHandler');
|
|
||||||
|
|
||||||
AndroidViewIntentHandler(Ref ref)
|
|
||||||
: _ref = ref,
|
|
||||||
_viewIntentService = ref.read(viewIntentServiceProvider),
|
|
||||||
_viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
|
|
||||||
_router = ref.watch(appRouterProvider);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void init() {
|
|
||||||
// Covers cold start from a view intent before the first lifecycle "resumed".
|
|
||||||
unawaited(onAppResumed());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> onAppResumed() => _checkForViewIntent();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> flushDeferredViewIntent() => _flushPending();
|
|
||||||
|
|
||||||
Future<void> _checkForViewIntent() async {
|
|
||||||
final attachment = await _viewIntentService.consumeViewIntent();
|
|
||||||
if (attachment != null) {
|
|
||||||
await handle(attachment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_ref.read(viewIntentPendingProvider) == null) {
|
|
||||||
await _viewIntentService.cleanupStaleTempFiles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _flushPending() async {
|
|
||||||
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
|
||||||
_logger.info('flushPending, pendingAttachment:$pendingAttachment');
|
|
||||||
if (pendingAttachment != null) {
|
|
||||||
await handle(pendingAttachment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> handle(ViewIntentPayload attachment) async {
|
|
||||||
_logger.info(
|
|
||||||
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!_ref.read(authProvider).isAuthenticated) {
|
|
||||||
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
|
|
||||||
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
|
|
||||||
await _openAssetViewer(
|
|
||||||
resolvedAsset.asset,
|
|
||||||
resolvedAsset.timelineService,
|
|
||||||
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
|
|
||||||
final notifier = _ref.read(assetViewerProvider.notifier);
|
|
||||||
notifier.reset();
|
|
||||||
if (asset.isVideo) {
|
|
||||||
notifier.setControls(false);
|
|
||||||
}
|
|
||||||
notifier.setAsset(asset);
|
|
||||||
|
|
||||||
if (viewIntentFilePath != null) {
|
|
||||||
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
|
|
||||||
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
|
|
||||||
} else {
|
|
||||||
_ref.read(viewIntentFilePathProvider.notifier).clear();
|
|
||||||
unawaited(_viewIntentService.cleanupManagedTempFile());
|
|
||||||
}
|
|
||||||
|
|
||||||
await _router.replaceAll([
|
|
||||||
const TabShellRoute(),
|
|
||||||
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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 {}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
|
||||||
|
|
||||||
final viewIntentNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
|
|
||||||
|
|
||||||
final viewIntentPendingProvider = NotifierProvider<ViewIntentPendingNotifier, ViewIntentPayload?>(
|
|
||||||
ViewIntentPendingNotifier.new,
|
|
||||||
);
|
|
||||||
|
|
||||||
class ViewIntentPendingNotifier extends Notifier<ViewIntentPayload?> {
|
|
||||||
static const _ttl = Duration(minutes: 10);
|
|
||||||
|
|
||||||
DateTime? _deferredAt;
|
|
||||||
|
|
||||||
@override
|
|
||||||
ViewIntentPayload? build() => null;
|
|
||||||
|
|
||||||
void defer(ViewIntentPayload attachment) {
|
|
||||||
_deferredAt = ref.read(viewIntentNowProvider)();
|
|
||||||
state = attachment;
|
|
||||||
}
|
|
||||||
|
|
||||||
ViewIntentPayload? takeIfFresh() {
|
|
||||||
final attachment = state;
|
|
||||||
final deferredAt = _deferredAt;
|
|
||||||
state = null;
|
|
||||||
_deferredAt = null;
|
|
||||||
|
|
||||||
if (attachment == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attachment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -151,7 +151,7 @@ class ForegroundUploadService {
|
|||||||
List<File> files, {
|
List<File> files, {
|
||||||
Completer<void>? cancelToken,
|
Completer<void>? cancelToken,
|
||||||
void Function(String fileId, int bytes, int totalBytes)? onProgress,
|
void Function(String fileId, int bytes, int totalBytes)? onProgress,
|
||||||
void Function(String fileId, String remoteAssetId)? onSuccess,
|
void Function(String fileId)? onSuccess,
|
||||||
void Function(String fileId, String errorMessage)? onError,
|
void Function(String fileId, String errorMessage)? onError,
|
||||||
}) async {
|
}) async {
|
||||||
if (files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
@@ -171,7 +171,7 @@ class ForegroundUploadService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
onSuccess?.call(fileId, result.remoteAssetId!);
|
onSuccess?.call(fileId);
|
||||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||||
onError?.call(fileId, result.errorMessage!);
|
onError?.call(fileId, result.errorMessage!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
|
|
||||||
|
|
||||||
class ViewIntentService {
|
|
||||||
final ViewIntentHostApi _viewIntentHostApi;
|
|
||||||
final Future<Directory> Function() _temporaryDirectory;
|
|
||||||
String? _managedTempFilePath;
|
|
||||||
final Set<String> _activeUploadPaths = {};
|
|
||||||
|
|
||||||
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
|
|
||||||
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
|
|
||||||
|
|
||||||
Future<ViewIntentPayload?> consumeViewIntent() async {
|
|
||||||
try {
|
|
||||||
return await _viewIntentHostApi.consumeViewIntent();
|
|
||||||
} catch (_) {
|
|
||||||
// Ignore errors - view intent might not be present
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setManagedTempFilePath(String path) async {
|
|
||||||
final previous = _managedTempFilePath;
|
|
||||||
if (previous == path) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_managedTempFilePath = path;
|
|
||||||
if (previous != null) {
|
|
||||||
await cleanupTempFile(previous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cleanupManagedTempFile() async {
|
|
||||||
final path = _managedTempFilePath;
|
|
||||||
_managedTempFilePath = null;
|
|
||||||
if (path != null) {
|
|
||||||
await cleanupTempFile(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
|
|
||||||
if (_managedTempFilePath == path) {
|
|
||||||
_managedTempFilePath = null;
|
|
||||||
}
|
|
||||||
await cleanupTempFile(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cleanupTempFile(String path) async {
|
|
||||||
if (!_isManagedTempFile(path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_activeUploadPaths.contains(path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final file = File(path);
|
|
||||||
if (await file.exists()) {
|
|
||||||
await file.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Best-effort cleanup only.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cleanupStaleTempFiles() async {
|
|
||||||
try {
|
|
||||||
final tempDirectory = await _temporaryDirectory();
|
|
||||||
await for (final entity in tempDirectory.list()) {
|
|
||||||
if (entity is! File) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final path = entity.path;
|
|
||||||
if (!_isManagedTempFile(path) ||
|
|
||||||
path == _managedTempFilePath ||
|
|
||||||
_activeUploadPaths.contains(path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await entity.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Best-effort cleanup only.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void markUploadActive(String path) {
|
|
||||||
_activeUploadPaths.add(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> markUploadInactive(String path) async {
|
|
||||||
if (!_activeUploadPaths.remove(path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_managedTempFilePath != path) {
|
|
||||||
await cleanupTempFile(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isManagedTempFile(String path) {
|
|
||||||
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
|
||||||
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
|
|
||||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
class ViewIntentResolvedAsset {
|
|
||||||
final BaseAsset asset;
|
|
||||||
final TimelineService timelineService;
|
|
||||||
|
|
||||||
final String? viewIntentFilePath;
|
|
||||||
|
|
||||||
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
|
|
||||||
}
|
|
||||||
|
|
||||||
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
|
|
||||||
(ref) => ViewIntentAssetResolver(
|
|
||||||
localAssetRepository: ref.read(localAssetRepository),
|
|
||||||
timelineFactory: ref.read(timelineFactoryProvider),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
class ViewIntentAssetResolver {
|
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
|
||||||
final TimelineFactory _timelineFactory;
|
|
||||||
static final Logger _logger = Logger('ViewIntentAssetResolver');
|
|
||||||
|
|
||||||
const ViewIntentAssetResolver({
|
|
||||||
required 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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
|
|
||||||
final asset = localAsset ?? _toTransientAsset(attachment);
|
|
||||||
|
|
||||||
return ViewIntentResolvedAsset(
|
|
||||||
asset: asset,
|
|
||||||
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
|
|
||||||
viewIntentFilePath: localAsset == null ? path : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
return LocalAsset(
|
|
||||||
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
|
|
||||||
name: attachment.fileName,
|
|
||||||
type: attachment.isVideo ? AssetType.video : AssetType.image,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
isEdited: false,
|
|
||||||
playbackStyle: attachment.playbackStyle,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
@@ -183,11 +182,9 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
|
|
||||||
Future<void> handleSyncFlow() async {
|
Future<void> handleSyncFlow() async {
|
||||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
|
||||||
|
|
||||||
await backgroundManager.syncLocal(full: true);
|
await backgroundManager.syncLocal(full: true);
|
||||||
await backgroundManager.syncRemote();
|
await backgroundManager.syncRemote();
|
||||||
await viewIntentHandler.flushDeferredViewIntent();
|
|
||||||
await backgroundManager.hashAssets();
|
await backgroundManager.hashAssets();
|
||||||
|
|
||||||
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||||
@@ -262,7 +259,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
unawaited(handleSyncFlow());
|
unawaited(handleSyncFlow());
|
||||||
ref.read(websocketProvider.notifier).connect();
|
ref.read(websocketProvider.notifier).connect();
|
||||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -349,7 +346,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
await getManageMediaPermission();
|
await getManageMediaPermission();
|
||||||
}
|
}
|
||||||
unawaited(handleSyncFlow());
|
unawaited(handleSyncFlow());
|
||||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
|
|||||||
+1
-2
@@ -29,8 +29,7 @@ run = [
|
|||||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||||
"dart run pigeon --input pigeon/network_api.dart",
|
"dart run pigeon --input pigeon/network_api.dart",
|
||||||
"dart 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",
|
||||||
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tasks."codegen:translation"]
|
[tasks."codegen:translation"]
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
dartOut: 'lib/platform/local_image_api.g.dart',
|
dartOut: 'lib/platform/local_image_api.g.dart',
|
||||||
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
|
swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
|
||||||
swiftOptions: SwiftOptions(includeErrorClass: false),
|
swiftOptions: SwiftOptions(includeErrorClass: false),
|
||||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
|
kotlinOut:
|
||||||
|
'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
|
||||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
|
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
|
||||||
dartOptions: DartOptions(),
|
dartOptions: DartOptions(),
|
||||||
dartPackageName: 'immich_mobile',
|
dartPackageName: 'immich_mobile',
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
|
||||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
|
||||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
|
|
||||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
|
||||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
|
||||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
|
||||||
import 'package:immich_mobile/services/widget.service.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
|
||||||
|
|
||||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
|
||||||
|
|
||||||
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
|
|
||||||
|
|
||||||
class MockAppRouter extends Mock implements AppRouter {}
|
|
||||||
|
|
||||||
class MockAuthService extends Mock implements AuthService {}
|
|
||||||
|
|
||||||
class MockApiService extends Mock implements ApiService {}
|
|
||||||
|
|
||||||
class MockUserService extends Mock implements UserService {}
|
|
||||||
|
|
||||||
class MockSecureStorageService extends Mock implements SecureStorageService {}
|
|
||||||
|
|
||||||
class MockWidgetService extends Mock implements WidgetService {}
|
|
||||||
|
|
||||||
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
|
|
||||||
|
|
||||||
class FakeTimelineService extends Fake implements TimelineService {}
|
|
||||||
|
|
||||||
class TestViewIntentService extends ViewIntentService {
|
|
||||||
ViewIntentPayload? consumedAttachment;
|
|
||||||
int cleanupStaleTempFilesCalls = 0;
|
|
||||||
int cleanupManagedTempFileCalls = 0;
|
|
||||||
final List<String> managedTempPaths = [];
|
|
||||||
|
|
||||||
TestViewIntentService() : super(MockViewIntentHostApi());
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> cleanupStaleTempFiles() async {
|
|
||||||
cleanupStaleTempFilesCalls++;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> cleanupManagedTempFile() async {
|
|
||||||
cleanupManagedTempFileCalls++;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> setManagedTempFilePath(String path) async {
|
|
||||||
managedTempPaths.add(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TestAuthNotifier extends AuthNotifier {
|
|
||||||
TestAuthNotifier(Ref ref, AuthState initial)
|
|
||||||
: super(
|
|
||||||
MockAuthService(),
|
|
||||||
MockApiService(),
|
|
||||||
MockUserService(),
|
|
||||||
MockSecureStorageService(),
|
|
||||||
MockWidgetService(),
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
state = initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAuthenticated(bool isAuthenticated) {
|
|
||||||
state = state.copyWith(isAuthenticated: isAuthenticated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
late TestViewIntentService viewIntentService;
|
|
||||||
late MockViewIntentAssetResolver resolver;
|
|
||||||
late MockAppRouter router;
|
|
||||||
late TestAuthNotifier authNotifier;
|
|
||||||
late ProviderContainer container;
|
|
||||||
late AndroidViewIntentHandler handler;
|
|
||||||
late ViewIntentPayload payload;
|
|
||||||
late LocalAsset deepLinkAsset;
|
|
||||||
late TimelineService deepLinkTimelineService;
|
|
||||||
|
|
||||||
setUpAll(() {
|
|
||||||
registerFallbackValue(FakePageRouteInfo());
|
|
||||||
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
|
|
||||||
registerFallbackValue(FakeTimelineService());
|
|
||||||
registerFallbackValue(
|
|
||||||
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
viewIntentService = TestViewIntentService();
|
|
||||||
resolver = MockViewIntentAssetResolver();
|
|
||||||
router = MockAppRouter();
|
|
||||||
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
|
|
||||||
deepLinkAsset = _localAsset(id: 'local-1');
|
|
||||||
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
|
|
||||||
|
|
||||||
when(() => router.replaceAll(any())).thenAnswer((_) async {});
|
|
||||||
|
|
||||||
container = ProviderContainer(
|
|
||||||
overrides: [
|
|
||||||
viewIntentServiceProvider.overrideWithValue(viewIntentService),
|
|
||||||
viewIntentAssetResolverProvider.overrideWithValue(resolver),
|
|
||||||
appRouterProvider.overrideWithValue(router),
|
|
||||||
authProvider.overrideWith((ref) {
|
|
||||||
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
|
|
||||||
return authNotifier;
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
|
|
||||||
handler = container.read(_handlerProvider);
|
|
||||||
|
|
||||||
addTearDown(() async {
|
|
||||||
await deepLinkTimelineService.dispose();
|
|
||||||
container.dispose();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handle defers unauthenticated attachment', () async {
|
|
||||||
authNotifier.setAuthenticated(false);
|
|
||||||
|
|
||||||
await handler.handle(payload);
|
|
||||||
|
|
||||||
expect(container.read(viewIntentPendingProvider), payload);
|
|
||||||
verifyNever(() => resolver.resolve(any()));
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('flushDeferredViewIntent consumes the pending attachment and routes the viewer', (tester) async {
|
|
||||||
authNotifier.setAuthenticated(false);
|
|
||||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
|
||||||
authNotifier.setAuthenticated(true);
|
|
||||||
|
|
||||||
when(() => resolver.resolve(payload)).thenAnswer((_) async {
|
|
||||||
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
|
|
||||||
});
|
|
||||||
|
|
||||||
unawaited(handler.flushDeferredViewIntent());
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
await tester.idle();
|
|
||||||
|
|
||||||
expect(container.read(viewIntentPendingProvider), isNull);
|
|
||||||
verify(() => resolver.resolve(payload)).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
|
|
||||||
await handler.flushDeferredViewIntent();
|
|
||||||
|
|
||||||
verifyNever(() => resolver.resolve(any()));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('onAppResumed cleans stale temp files when no attachment is present', () async {
|
|
||||||
viewIntentService.consumedAttachment = null;
|
|
||||||
|
|
||||||
await handler.onAppResumed();
|
|
||||||
|
|
||||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
|
|
||||||
verifyNever(() => resolver.resolve(any()));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
|
|
||||||
viewIntentService.consumedAttachment = null;
|
|
||||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
|
||||||
|
|
||||||
await handler.onAppResumed();
|
|
||||||
|
|
||||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
|
|
||||||
verifyNever(() => resolver.resolve(any()));
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
|
|
||||||
viewIntentService.consumedAttachment = payload;
|
|
||||||
when(() => resolver.resolve(payload)).thenAnswer(
|
|
||||||
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
|
|
||||||
);
|
|
||||||
|
|
||||||
unawaited(handler.onAppResumed());
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
await tester.pump();
|
|
||||||
await tester.idle();
|
|
||||||
|
|
||||||
verify(() => resolver.resolve(payload)).called(1);
|
|
||||||
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
|
|
||||||
// main timeline — mirrors the home-screen widget navigation pattern.
|
|
||||||
final captured = verify(() => router.replaceAll(captureAny())).captured;
|
|
||||||
expect(captured, hasLength(1));
|
|
||||||
final routes = captured.single as List<PageRouteInfo<dynamic>>;
|
|
||||||
expect(routes, hasLength(2));
|
|
||||||
expect(routes[0].routeName, TabShellRoute.name);
|
|
||||||
expect(routes[1].routeName, AssetViewerRoute.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
AuthState _authState({required bool isAuthenticated}) {
|
|
||||||
return AuthState(
|
|
||||||
deviceId: 'device-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
userEmail: 'user@example.com',
|
|
||||||
isAuthenticated: isAuthenticated,
|
|
||||||
name: 'User',
|
|
||||||
isAdmin: false,
|
|
||||||
profileImagePath: '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalAsset _localAsset({required String id}) {
|
|
||||||
return LocalAsset(
|
|
||||||
id: id,
|
|
||||||
name: '$id.jpg',
|
|
||||||
checksum: 'checksum-1',
|
|
||||||
type: AssetType.image,
|
|
||||||
createdAt: DateTime(2026, 4, 20),
|
|
||||||
updatedAt: DateTime(2026, 4, 20),
|
|
||||||
playbackStyle: AssetPlaybackStyle.image,
|
|
||||||
isEdited: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
|
||||||
return TimelineService((
|
|
||||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
|
||||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
|
||||||
origin: origin,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
|
|
||||||
final timelineService = _timelineServiceFromAssets(assets, origin);
|
|
||||||
// Spin a few async ticks so the internal bucket subscription has populated
|
|
||||||
// the buffer before tests start asserting against totalAssets.
|
|
||||||
for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) {
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
}
|
|
||||||
return timelineService;
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
|
||||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
|
||||||
|
|
||||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late MockViewIntentHostApi hostApi;
|
|
||||||
late ViewIntentService service;
|
|
||||||
late Directory tempRoot;
|
|
||||||
late Directory cacheDir;
|
|
||||||
|
|
||||||
final attachment = ViewIntentPayload(
|
|
||||||
path: '/tmp/file.jpg',
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
localAssetId: '42',
|
|
||||||
);
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
hostApi = MockViewIntentHostApi();
|
|
||||||
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
|
|
||||||
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
|
|
||||||
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
clearInteractions(hostApi);
|
|
||||||
if (await tempRoot.exists()) {
|
|
||||||
await tempRoot.delete(recursive: true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('consumeViewIntent returns null when no attachment', () async {
|
|
||||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
|
|
||||||
|
|
||||||
final result = await service.consumeViewIntent();
|
|
||||||
|
|
||||||
expect(result, isNull);
|
|
||||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('consumeViewIntent returns attachment when present', () async {
|
|
||||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
|
|
||||||
|
|
||||||
final result = await service.consumeViewIntent();
|
|
||||||
|
|
||||||
expect(result, attachment);
|
|
||||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('consumeViewIntent swallows host api errors', () async {
|
|
||||||
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
|
|
||||||
|
|
||||||
final result = await service.consumeViewIntent();
|
|
||||||
|
|
||||||
expect(result, isNull);
|
|
||||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setManagedTempFilePath cleans previous managed temp file', () async {
|
|
||||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
|
||||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
|
||||||
|
|
||||||
await service.setManagedTempFilePath(firstFile.path);
|
|
||||||
await service.setManagedTempFilePath(secondFile.path);
|
|
||||||
|
|
||||||
expect(await firstFile.exists(), isFalse);
|
|
||||||
expect(await secondFile.exists(), isTrue);
|
|
||||||
|
|
||||||
await service.cleanupManagedTempFile();
|
|
||||||
expect(await secondFile.exists(), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cleanupTempFile defers deletion while an upload is active', () async {
|
|
||||||
final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes');
|
|
||||||
|
|
||||||
service.markUploadActive(tempFile.path);
|
|
||||||
await service.cleanupTempFile(tempFile.path);
|
|
||||||
|
|
||||||
expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup');
|
|
||||||
|
|
||||||
await service.markUploadInactive(tempFile.path);
|
|
||||||
expect(await tempFile.exists(), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cleanupTempFile ignores non-managed paths', () async {
|
|
||||||
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
|
|
||||||
|
|
||||||
await service.cleanupTempFile(nonManagedFile.path);
|
|
||||||
|
|
||||||
expect(await nonManagedFile.exists(), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
|
|
||||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
|
||||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
|
||||||
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
|
|
||||||
|
|
||||||
await service.cleanupStaleTempFiles();
|
|
||||||
|
|
||||||
expect(await firstFile.exists(), isFalse);
|
|
||||||
expect(await secondFile.exists(), isFalse);
|
|
||||||
expect(await unrelatedFile.exists(), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cleanupStaleTempFiles skips paths with active uploads', () async {
|
|
||||||
final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale');
|
|
||||||
final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active');
|
|
||||||
service.markUploadActive(active.path);
|
|
||||||
|
|
||||||
await service.cleanupStaleTempFiles();
|
|
||||||
|
|
||||||
expect(await stale.exists(), isFalse);
|
|
||||||
expect(await active.exists(), isTrue);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Generated
+5
-5
@@ -758,8 +758,8 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/sdk
|
version: link:../packages/sdk
|
||||||
'@immich/ui':
|
'@immich/ui':
|
||||||
specifier: ^0.77.0
|
specifier: ^0.79.0
|
||||||
version: 0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||||
'@mapbox/mapbox-gl-rtl-text':
|
'@mapbox/mapbox-gl-rtl-text':
|
||||||
specifier: 0.4.0
|
specifier: 0.4.0
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
@@ -3204,8 +3204,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@immich/ui@0.77.3':
|
'@immich/ui@0.79.0':
|
||||||
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==}
|
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@sveltejs/kit': ^2.13.0
|
'@sveltejs/kit': ^2.13.0
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
@@ -15879,7 +15879,7 @@ snapshots:
|
|||||||
pg-connection-string: 2.13.0
|
pg-connection-string: 2.13.0
|
||||||
postgres: 3.4.9
|
postgres: 3.4.9
|
||||||
|
|
||||||
'@immich/ui@0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
'@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@internationalized/date': 3.12.1
|
'@internationalized/date': 3.12.1
|
||||||
'@mdi/js': 7.4.47
|
'@mdi/js': 7.4.47
|
||||||
|
|||||||
+2
-2
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
|
|||||||
COPY --from=server /output/server-pruned ./server
|
COPY --from=server /output/server-pruned ./server
|
||||||
COPY --from=web /usr/src/app/web/build /build/www
|
COPY --from=web /usr/src/app/web/build /build/www
|
||||||
COPY --from=cli /output/cli-pruned ./cli
|
COPY --from=cli /output/cli-pruned ./cli
|
||||||
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist
|
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
|
||||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json
|
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
|
||||||
RUN ln -s ../../cli/bin/immich server/bin/immich
|
RUN ln -s ../../cli/bin/immich server/bin/immich
|
||||||
COPY LICENSE /licenses/LICENSE.txt
|
COPY LICENSE /licenses/LICENSE.txt
|
||||||
COPY LICENSE /LICENSE
|
COPY LICENSE /LICENSE
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
|
||||||
|
}
|
||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||||
"@immich/justified-layout-wasm": "^0.4.3",
|
"@immich/justified-layout-wasm": "^0.4.3",
|
||||||
"@immich/sdk": "workspace:*",
|
"@immich/sdk": "workspace:*",
|
||||||
"@immich/ui": "^0.77.0",
|
"@immich/ui": "^0.79.0",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar showBackButton={false}>
|
<ControlAppBar>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<header>
|
<header>
|
||||||
<ControlAppBar showBackButton={false}>
|
<ControlAppBar>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant="inline" />
|
<Logo variant="inline" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { IconButton, Logo, toastManager } from '@immich/ui';
|
import { IconButton, Logo, toastManager } from '@immich/ui';
|
||||||
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
|
import { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
|
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
|
||||||
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
|
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
|
<ControlAppBar>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||||
|
|||||||
@@ -1,97 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
|
||||||
import { IconButton } from '@immich/ui';
|
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiClose } from '@mdi/js';
|
||||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showBackButton?: boolean;
|
|
||||||
backIcon?: string;
|
backIcon?: string;
|
||||||
tailwindClasses?: string;
|
class?: ClassValue;
|
||||||
forceDark?: boolean;
|
|
||||||
multiRow?: boolean;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
title?: Snippet | string;
|
||||||
leading?: Snippet;
|
leading?: Snippet;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
trailing?: Snippet;
|
trailing?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
|
||||||
showBackButton = true,
|
|
||||||
backIcon = mdiClose,
|
|
||||||
tailwindClasses = '',
|
|
||||||
forceDark = false,
|
|
||||||
multiRow = false,
|
|
||||||
onClose = () => {},
|
|
||||||
leading,
|
|
||||||
children,
|
|
||||||
trailing,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let appBarBorder = $state('border border-subtle');
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
if (window.scrollY > 80) {
|
|
||||||
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
|
|
||||||
|
|
||||||
if (forceDark) {
|
|
||||||
appBarBorder = 'border border-gray-600';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
appBarBorder = 'border border-subtle';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (browser) {
|
|
||||||
document.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (browser) {
|
|
||||||
document.removeEventListener('scroll', onScroll);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
|
<div class={['absolute top-0 w-full bg-transparent p-2']}>
|
||||||
<nav
|
<ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
|
||||||
id="asset-selection-app-bar"
|
{#if title || leading}
|
||||||
class={[
|
<ControlBarHeader>
|
||||||
'grid',
|
{#if title}
|
||||||
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]',
|
<ControlBarTitle>
|
||||||
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
|
{#if typeof title === 'string'}
|
||||||
'justify-between lg:grid-cols-[25%_50%_25%]',
|
{title}
|
||||||
appBarBorder,
|
{:else}
|
||||||
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
|
{@render title()}
|
||||||
tailwindClasses,
|
{/if}
|
||||||
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
|
</ControlBarTitle>
|
||||||
]}
|
{/if}
|
||||||
>
|
{@render leading?.()}
|
||||||
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
|
</ControlBarHeader>
|
||||||
{#if showBackButton}
|
{/if}
|
||||||
<IconButton
|
|
||||||
aria-label={$t('close')}
|
|
||||||
onclick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
icon={backIcon}
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{@render leading?.()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
{#if children}
|
||||||
{@render children?.()}
|
<ControlBarContent>
|
||||||
</div>
|
{@render children()}
|
||||||
|
</ControlBarContent>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0">
|
{#if trailing}
|
||||||
{@render trailing?.()}
|
<ControlBarOverflow>
|
||||||
</div>
|
{@render trailing()}
|
||||||
</nav>
|
</ControlBarOverflow>
|
||||||
|
{/if}
|
||||||
|
</ControlBar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,19 +7,18 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
forceDark?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let { children, forceDark }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
const onClose = () => assetMultiSelectManager.clear();
|
const onClose = () => assetMultiSelectManager.clear();
|
||||||
|
|
||||||
const assets = $derived(assetMultiSelectManager.assets);
|
const assets = $derived(assetMultiSelectManager.assets);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
|
<ControlAppBar {onClose} backIcon={mdiClose}>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}">
|
<div class="font-medium text-primary">
|
||||||
<p class="block sm:hidden">{assets.length}</p>
|
<p class="block sm:hidden">{assets.length}</p>
|
||||||
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, invalidate, onNavigate } from '$app/navigation';
|
import { goto, invalidate, onNavigate } from '$app/navigation';
|
||||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import AlbumDescription from './AlbumDescription.svelte';
|
|
||||||
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
|
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
|
||||||
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
|
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
|
||||||
import AlbumTitle from './AlbumTitle.svelte';
|
|
||||||
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
|
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
|
||||||
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
|
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
|
||||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||||
@@ -78,6 +76,8 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import AlbumDescription from './AlbumDescription.svelte';
|
||||||
|
import AlbumTitle from './AlbumTitle.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@@ -499,7 +499,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
{#if viewMode === AlbumPageViewMode.VIEW}
|
{#if viewMode === AlbumPageViewMode.VIEW}
|
||||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<ActionButton action={Cast} />
|
<ActionButton action={Cast} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
|
|
||||||
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
|
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||||
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
|
|
||||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||||
@@ -37,6 +34,7 @@
|
|||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiChevronUp,
|
mdiChevronUp,
|
||||||
|
mdiClose,
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
mdiHeart,
|
mdiHeart,
|
||||||
mdiHeartOutline,
|
mdiHeartOutline,
|
||||||
@@ -54,6 +52,8 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { Attachment } from 'svelte/attachments';
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
|
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
|
||||||
|
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
|
||||||
|
|
||||||
let memoryGallery: HTMLElement | undefined = $state();
|
let memoryGallery: HTMLElement | undefined = $state();
|
||||||
let memoryWrapper: HTMLElement | undefined = $state();
|
let memoryWrapper: HTMLElement | undefined = $state();
|
||||||
@@ -327,8 +327,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if assetMultiSelectManager.selectionActive}
|
{#if assetMultiSelectManager.selectionActive}
|
||||||
<div class="dark sticky top-0 z-1">
|
<div class="sticky top-0 z-1 dark">
|
||||||
<AssetSelectControlBar forceDark>
|
<AssetSelectControlBar>
|
||||||
{@const Actions = getAssetBulkActions($t)}
|
{@const Actions = getAssetBulkActions($t)}
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -365,22 +365,33 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="memory-viewer"
|
id="memory-viewer"
|
||||||
class="w-full bg-immich-dark-gray"
|
class="dark w-full text-white bg-immich-dark-gray"
|
||||||
bind:this={memoryWrapper}
|
bind:this={memoryWrapper}
|
||||||
bind:clientHeight={viewport.height}
|
bind:clientHeight={viewport.height}
|
||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
<div
|
||||||
{#snippet leading()}
|
class="max-md:h-auto max-md:flex-col dark grid grid-cols-[100%] md:grid-cols-[25%_50%_25%] px-2 py-2 md:px-4 md:py-4"
|
||||||
{#if current}
|
>
|
||||||
|
{#if current}
|
||||||
|
<div class="flex gap-2 md:gap-6 items-center">
|
||||||
|
<IconButton
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
icon={mdiClose}
|
||||||
|
aria-label={$t('close')}
|
||||||
|
size="large"
|
||||||
|
onclick={() => goto(Route.photos())}
|
||||||
|
/>
|
||||||
<p class="text-lg">
|
<p class="text-lg">
|
||||||
{$memoryLaneTitle(current.memory)}
|
{$memoryLaneTitle(current.memory)}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
</div>
|
||||||
{/snippet}
|
{/if}
|
||||||
|
|
||||||
<div class="dark flex place-content-center place-items-center gap-2">
|
<div class="dark flex w-full place-content-center place-items-center gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -438,7 +449,7 @@
|
|||||||
</media-mute-button>
|
</media-mute-button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</ControlAppBar>
|
</div>
|
||||||
|
|
||||||
{#if galleryInView}
|
{#if galleryInView}
|
||||||
<div
|
<div
|
||||||
@@ -462,7 +473,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Viewer -->
|
<!-- Viewer -->
|
||||||
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}>
|
<section class="overflow-hidden pt-6 md:pt-0" bind:clientHeight={viewerHeight}>
|
||||||
<div
|
<div
|
||||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
|
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@
|
|||||||
<DownloadAction />
|
<DownloadAction />
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
|
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
|
||||||
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
|
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
|
||||||
|
|||||||
+4
-4
@@ -5,9 +5,6 @@
|
|||||||
import { listNavigation } from '$lib/actions/list-navigation';
|
import { listNavigation } from '$lib/actions/list-navigation';
|
||||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||||
import EditNameInput from './EditNameInput.svelte';
|
|
||||||
import MergeFaceSelector from './MergeFaceSelector.svelte';
|
|
||||||
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
|
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||||
@@ -54,6 +51,9 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import EditNameInput from './EditNameInput.svelte';
|
||||||
|
import MergeFaceSelector from './MergeFaceSelector.svelte';
|
||||||
|
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<ContextMenuButton
|
<ContextMenuButton
|
||||||
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
||||||
|
|||||||
@@ -387,8 +387,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="fixed inset-s-0 top-0 z-2 w-full">
|
<div class="fixed inset-s-0 top-0 z-2 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="absolute bg-light"></div>
|
<div class="mx-auto w-full max-w-2xl pe-2">
|
||||||
<div class="w-full flex-1 ps-4">
|
|
||||||
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||||
</div>
|
</div>
|
||||||
</ControlAppBar>
|
</ControlAppBar>
|
||||||
|
|||||||
Reference in New Issue
Block a user