Compare commits

..

4 Commits

Author SHA1 Message Date
bwees faaeaace1d fix: memory viewer bar 2026-05-22 16:50:18 -05:00
bwees 2837de2029 chore: ci fix 2026-05-22 14:01:11 -05:00
bwees eb27635f22 refactor: use ControlBar UI Library component 2026-05-22 11:35:16 -05:00
Peter Ombodi 78ac0ade01 feat(mobile): add manage media APIs to NativeSyncApi (#28441)
* feat(mobile): add manage media APIs to NativeSyncApi

* fix(mobile): remove legacy local file manager from trash sync

* refactor(mobile): move media permission methods to PermissionApi

* cleanup

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-22 17:40:11 +05:30
47 changed files with 1164 additions and 453 deletions
@@ -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);
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.permission.PermissionApi
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
@@ -44,7 +46,9 @@ class MainActivity : FlutterFragmentActivity() {
} else { } else {
NativeSyncApiImpl30(ctx) NativeSyncApiImpl30(ctx)
} }
val permissionApiImpl = PermissionApiImpl(ctx)
NativeSyncApi.setUp(messenger, nativeSyncApiImpl) NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
PermissionApi.setUp(messenger, permissionApiImpl)
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx)) LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx)) RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
@@ -53,6 +57,7 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl) flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
} }
fun cancelPlugins(flutterEngine: FlutterEngine) { fun cancelPlugins(flutterEngine: FlutterEngine) {
@@ -60,6 +65,8 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin? flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin? ?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
nativeApi?.detachFromEngine() nativeApi?.detachFromEngine()
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
permissionApi?.detachFromEngine()
} }
} }
} }
@@ -0,0 +1,96 @@
package app.alextran.immich.permission
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.MediaStore
import android.provider.Settings
import androidx.core.net.toUri
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
class ManageMediaPermissionDelegate(
context: Context,
private val requestCode: Int = 1003,
) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(ctx)
} else {
false
}
}
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
if (hasManageMediaPermission()) {
callback(Result.success(true))
return
}
openManageMediaPermissionSettings(callback)
}
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
openManageMediaPermissionSettings(callback)
}
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
callback(Result.success(false))
return
}
val activity = activityBinding?.activity
if (activity == null) {
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
return
}
pendingResult = callback
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
data = "package:${activity.packageName}".toUri()
}
try {
activity.startActivityForResult(intent, requestCode)
} catch (e: Exception) {
pendingResult = null
callback(
Result.failure(
FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
)
)
}
}
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
fun onDetachedFromActivity() {
failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == this.requestCode) {
val callback = pendingResult
pendingResult = null
callback?.invoke(Result.success(hasManageMediaPermission()))
return true
}
return false
}
private fun failPending(code: String, message: String) {
val callback = pendingResult ?: return
pendingResult = null
callback(Result.failure(FlutterError(code, message, null)))
}
}
@@ -0,0 +1,128 @@
// 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.permission
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 PermissionApiPigeonUtils {
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)
)
}
}
}
/**
* 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()
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
companion object {
/** The codec used by PermissionApi. */
val codec: MessageCodec<Any?> by lazy {
PermissionApiPigeonCodec()
}
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasManageMediaPermission())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.requestManageMediaPermission{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PermissionApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.manageMediaPermission{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PermissionApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,37 @@
package app.alextran.immich.permission
import android.content.Context
import app.alextran.immich.core.ImmichPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
override fun hasManageMediaPermission(): Boolean =
manageMediaPermissionDelegate.hasManageMediaPermission()
override fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
}
override fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
manageMediaPermissionDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
manageMediaPermissionDelegate.onDetachedFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
manageMediaPermissionDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
manageMediaPermissionDelegate.onDetachedFromActivity()
}
}
@@ -0,0 +1,133 @@
package app.alextran.immich.sync
import android.app.Activity
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
class MediaTrashDelegate(
context: Context,
private val trashRequestCode: Int = 1002,
) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
private fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(ctx)
} else {
false
}
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
return
}
val id = mediaId.toLongOrNull()
if (id == null) {
callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
return
}
if (!isInTrash(id)) {
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
return
}
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreUri(
contentUri: Uri,
callback: (Result<Boolean>) -> Unit,
) {
val activity = activityBinding?.activity
if (activity == null) {
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
return
}
try {
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
pendingResult = callback
activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null,
0,
0,
0,
)
} catch (e: Exception) {
pendingResult = null
callback(
Result.failure(
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
)
)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun isInTrash(id: Long): Boolean {
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val args = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
}
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
?.use { it.moveToFirst() } == true
}
private fun contentUriForType(type: Int): Uri =
when (type) {
// Same order as AssetType from Dart.
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
}
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
fun onDetachedFromActivity() {
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == trashRequestCode) {
val callback = pendingResult
pendingResult = null
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
return true
}
return false
}
private fun failPending(code: String, message: String) {
val callback = pendingResult ?: return
pendingResult = null
callback(Result.failure(FlutterError(code, message, null)))
}
}
@@ -553,6 +553,7 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object { companion object {
@@ -747,6 +748,27 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val mediaIdArg = args[0] as String
val typeArg = args[1] as Long
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -39,10 +41,11 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase" private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private var hashTask: Job? = null private var hashTask: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object { companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16 private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -448,6 +451,26 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask = null hashTask = null
} }
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
mediaTrashDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
mediaTrashDelegate.onDetachedFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
mediaTrashDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
mediaTrashDelegate.onDetachedFromActivity()
}
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs // This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER") @Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> { fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
@@ -19,6 +19,8 @@
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; }; B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; }; B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; }; B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; }; B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; }; D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -105,6 +107,8 @@
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; }; B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; }; B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; }; B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; }; B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -283,6 +287,7 @@
B25D37792E72CA15008B6CA7 /* Connectivity */, B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */, B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
B2EE00052E72CA15008B6CA7 /* Permission */,
FA9973382CF6DF4B000EF859 /* Runner.entitlements */, FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -317,6 +322,15 @@
path = Connectivity; path = Connectivity;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B2EE00052E72CA15008B6CA7 /* Permission */ = {
isa = PBXGroup;
children = (
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
);
path = Permission;
sourceTree = "<group>";
};
FAC6F8B62D287F120078CB2F /* ShareExtension */ = { FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -619,6 +633,8 @@
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */, FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */, FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */, FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */, B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
+1
View File
@@ -26,6 +26,7 @@ import native_video_player
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) { public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!) NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl()) LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl()) RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
+106
View File
@@ -0,0 +1,106 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(Swift.type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PermissionApi {
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup {
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
do {
let result = try api.hasManageMediaPermission()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
hasManageMediaPermissionChannel.setMessageHandler(nil)
}
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
api.requestManageMediaPermission { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestManageMediaPermissionChannel.setMessageHandler(nil)
}
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
manageMediaPermissionChannel.setMessageHandler { _, reply in
api.manageMediaPermission { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
manageMediaPermissionChannel.setMessageHandler(nil)
}
}
}
@@ -0,0 +1,15 @@
import Foundation
class PermissionApiImpl: PermissionApi {
func hasManageMediaPermission() throws -> Bool {
return false
}
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
}
+19
View File
@@ -537,6 +537,7 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
} }
@@ -721,6 +722,24 @@ class NativeSyncApiSetup {
} else { } else {
getTrashedAssetsChannel.setMessageHandler(nil) getTrashedAssetsChannel.setMessageHandler(nil)
} }
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let mediaIdArg = args[0] as! String
let typeArg = args[1] as! Int64
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
restoreFromTrashByIdChannel.setMessageHandler(nil)
}
let getCloudIdForAssetIdsChannel = taskQueue == nil let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
@@ -383,6 +383,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
} }
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> { private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album // Ensure to actually getting all assets for the Recents album
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) { if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
@@ -9,10 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -23,29 +23,29 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager; final AssetMediaRepository _assetMediaRepository;
final StorageRepository _storageRepository; final IPermissionRepository _permissionRepository;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
LocalSyncService({ LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager, required AssetMediaRepository assetMediaRepository,
required StorageRepository storageRepository, required IPermissionRepository permissionRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager, _assetMediaRepository = assetMediaRepository,
_storageRepository = storageRepository, _permissionRepository = permissionRepository,
_nativeSyncApi = nativeSyncApi; _nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async { Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
try { try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission(); final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (hasPermission) { if (hasPermission) {
await _syncTrashedAssets(); await _syncTrashedAssets();
} else { } else {
@@ -373,7 +373,7 @@ class LocalSyncService {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) { if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore); final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds); await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else { } else {
_log.info("syncTrashedAssets, No remote assets found for restoration"); _log.info("syncTrashedAssets, No remote assets found for restoration");
@@ -381,15 +381,15 @@ class LocalSyncService {
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash(); final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) { if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait( final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
localAssetsToTrash.values _log.info("Moving to trash ${localIds.join(", ")} assets");
.expand((e) => e) final movedIds = await _assetMediaRepository.deleteAll(localIds);
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())), if (movedIds.isNotEmpty) {
); final movedAssetsByAlbum = localAssetsToTrash.map(
_log.info("Moving to trash ${mediaUrls.join(", ")} assets"); (albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); )..removeWhere((_, assets) => assets.isEmpty);
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash); await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
} }
} else { } else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash"); _log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -34,8 +34,8 @@ class SyncStreamService {
final SyncStreamRepository _syncStreamRepository; final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager; final AssetMediaRepository _assetMediaRepository;
final StorageRepository _storageRepository; final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository; final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api; final ApiService _api;
final bool Function()? _cancelChecker; final bool Function()? _cancelChecker;
@@ -45,8 +45,8 @@ class SyncStreamService {
required SyncStreamRepository syncStreamRepository, required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager, required AssetMediaRepository assetMediaRepository,
required StorageRepository storageRepository, required IPermissionRepository permissionRepository,
required SyncMigrationRepository syncMigrationRepository, required SyncMigrationRepository syncMigrationRepository,
required ApiService api, required ApiService api,
bool Function()? cancelChecker, bool Function()? cancelChecker,
@@ -54,8 +54,8 @@ class SyncStreamService {
_syncStreamRepository = syncStreamRepository, _syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager, _assetMediaRepository = assetMediaRepository,
_storageRepository = storageRepository, _permissionRepository = permissionRepository,
_syncMigrationRepository = syncMigrationRepository, _syncMigrationRepository = syncMigrationRepository,
_api = api, _api = api,
_cancelChecker = cancelChecker; _cancelChecker = cancelChecker;
@@ -500,22 +500,22 @@ class SyncStreamService {
} }
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async { Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final mediaUrls = await Future.wait( final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
localAssetsToTrash.values _logger.info("Moving to trash ${localIds.join(", ")} assets");
.expand((e) => e) final movedIds = await _assetMediaRepository.deleteAll(localIds);
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())), if (movedIds.isNotEmpty) {
); final movedAssetsByAlbum = localAssetsToTrash.map(
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets"); (albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); )..removeWhere((_, assets) => assets.isEmpty);
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash); await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
} }
} }
Future<void> _applyRemoteRestoreToLocal() async { Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) { if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore); final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds); await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else { } else {
_logger.info("No remote assets found for restoration"); _logger.info("No remote assets found for restoration");
@@ -523,7 +523,7 @@ class SyncStreamService {
} }
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async { Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) { if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing"); _logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return; return;
} }
@@ -533,7 +533,7 @@ class SyncStreamService {
} }
Future<void> _syncAssetDeletion(List<String> remoteIds) async { Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) { if (!(await _permissionRepository.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing"); _logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return; return;
} }
+19
View File
@@ -654,6 +654,25 @@ class NativeSyncApi {
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 {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async { Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final pigeonVar_channelName = final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
+119
View File
@@ -0,0 +1,119 @@
// 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;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class PermissionApi {
/// Constructor for [PermissionApi]. 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.
PermissionApi({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<bool> hasManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$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: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> requestManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$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: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> manageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$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: false,
);
return pigeonVar_replyValue! as bool;
}
}
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_api.g.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/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart'; import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart'; import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -16,6 +17,8 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi()); final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi()); final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final localImageApi = LocalImageApi(); final localImageApi = LocalImageApi();
@@ -11,8 +11,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.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/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider))); final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -22,8 +22,8 @@ final syncStreamServiceProvider = Provider(
syncStreamRepository: ref.watch(syncStreamRepositoryProvider), syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider), assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider), syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider), api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider), cancelChecker: ref.watch(cancellationProvider),
@@ -39,8 +39,8 @@ final localSyncServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider), assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
), ),
); );
@@ -8,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider))); final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
class AssetMediaRepository { class AssetMediaRepository {
final AssetApiRepository _assetApiRepository; final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository"); static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository); const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
Future<bool> _androidSupportsTrash() async { Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
@@ -45,6 +50,27 @@ class AssetMediaRepository {
return PhotoManager.editor.deleteWithIds(ids); return PhotoManager.editor.deleteWithIds(ids);
} }
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
try {
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
} catch (e, s) {
_log.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
final result = await _restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
}
return restoredIds;
}
Future<AssetEntity?> get(String id) async { Future<AssetEntity?> get(String id) async {
final entity = await AssetEntity.fromId(id); final entity = await AssetEntity.fromId(id);
return entity; return entity;
@@ -1,51 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/services/local_files_manager.service.dart';
import 'package:logging/logging.dart';
final localFilesManagerRepositoryProvider = Provider(
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
);
class LocalFilesManagerRepository {
LocalFilesManagerRepository(this._service);
final Logger _logger = Logger('LocalFilesManagerRepo');
final LocalFilesManagerService _service;
Future<bool> moveToTrash(List<String> mediaUrls) async {
return await _service.moveToTrash(mediaUrls);
}
Future<bool> restoreFromTrash(String fileName, int type) async {
return await _service.restoreFromTrash(fileName, type);
}
Future<bool> requestManageMediaPermission() async {
return await _service.requestManageMediaPermission();
}
Future<bool> hasManageMediaPermission() async {
return await _service.hasManageMediaPermission();
}
Future<bool> manageMediaPermission() async {
return await _service.manageMediaPermission();
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
try {
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
} catch (e) {
_logger.warning("Restoring failure: $e");
}
}
return restoredIds;
}
}
@@ -1,12 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
final permissionRepositoryProvider = Provider((_) { final permissionRepositoryProvider = Provider((ref) {
return const PermissionRepository(); return PermissionRepository(ref.watch(permissionApiProvider));
}); });
class PermissionRepository implements IPermissionRepository { class PermissionRepository implements IPermissionRepository {
const PermissionRepository(); final PermissionApi _permissionApi;
const PermissionRepository(this._permissionApi);
@override @override
Future<bool> hasLocationWhenInUsePermission() { Future<bool> hasLocationWhenInUsePermission() {
@@ -34,6 +38,21 @@ class PermissionRepository implements IPermissionRepository {
Future<bool> openSettings() { Future<bool> openSettings() {
return openAppSettings(); return openAppSettings();
} }
@override
Future<bool> hasManageMediaPermission() {
return _permissionApi.hasManageMediaPermission();
}
@override
Future<bool> requestManageMediaPermission() {
return _permissionApi.requestManageMediaPermission();
}
@override
Future<bool> manageMediaPermission() {
return _permissionApi.manageMediaPermission();
}
} }
abstract interface class IPermissionRepository { abstract interface class IPermissionRepository {
@@ -42,4 +61,7 @@ abstract interface class IPermissionRepository {
Future<bool> hasLocationAlwaysPermission(); Future<bool> hasLocationAlwaysPermission();
Future<bool> requestLocationAlwaysPermission(); Future<bool> requestLocationAlwaysPermission();
Future<bool> openSettings(); Future<bool> openSettings();
Future<bool> hasManageMediaPermission();
Future<bool> requestManageMediaPermission();
Future<bool> manageMediaPermission();
} }
@@ -1,66 +0,0 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
class LocalFilesManagerService {
const LocalFilesManagerService();
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash');
Future<bool> moveToTrash(List<String> mediaUrls) async {
try {
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
} catch (e, s) {
_logger.warning('Error moving file to trash', e, s);
return false;
}
}
Future<bool> restoreFromTrash(String fileName, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash', e, s);
return false;
}
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<bool> requestManageMediaPermission() async {
try {
return await _channel.invokeMethod('requestManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission', e, s);
return false;
}
}
Future<bool> hasManageMediaPermission() async {
try {
return await _channel.invokeMethod('hasManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission state', e, s);
return false;
}
}
Future<bool> manageMediaPermission() async {
try {
return await _channel.invokeMethod('manageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission settings', e, s);
return false;
}
}
}
@@ -22,7 +22,7 @@ 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/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
} }
getManageMediaPermission() async { getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission(); final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) { if (!hasPermission) {
await showDialog( await showDialog(
context: context, context: context,
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text( child: Text(
@@ -10,7 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.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';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -57,9 +57,7 @@ class AdvancedSettings extends HookConsumerWidget {
() async { () async {
isManageMediaSupported.value = await checkAndroidVersion(); isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) { if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
.read(localFilesManagerRepositoryProvider)
.hasManageMediaPermission();
} }
}(); }();
return null; return null;
@@ -82,7 +80,7 @@ class AdvancedSettings extends HookConsumerWidget {
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(), subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async { onChanged: (value) async {
if (value) { if (value) {
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result; manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result; manageMediaAndroidPermission.value = result;
} }
@@ -96,7 +94,7 @@ class AdvancedSettings extends HookConsumerWidget {
? const Color.fromARGB(255, 243, 188, 106) ? const Color.fromARGB(255, 243, 188, 106)
: null, : null,
onActionTap: () async { onActionTap: () async {
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission(); final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result; manageMediaAndroidPermission.value = result;
}, },
), ),
+4 -8
View File
@@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile', dartPackageName: 'immich_mobile',
), ),
) )
enum PlatformAssetPlaybackStyle { enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset { class PlatformAsset {
final String id; final String id;
@@ -142,6 +135,9 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets(); Map<String, List<PlatformAsset>> getTrashedAssets();
@async
bool restoreFromTrashById(String mediaId, int type);
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds); List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
} }
+23
View File
@@ -0,0 +1,23 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/permission_api.g.dart',
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
swiftOptions: SwiftOptions(),
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
@HostApi()
abstract class PermissionApi {
bool hasManageMediaPermission();
@async
bool requestManageMediaPermission();
@async
bool manageMediaPermission();
}
@@ -10,17 +10,15 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart'; import '../../domain/service.mock.dart';
import '../../fixtures/asset.stub.dart'; import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart'; import '../../repository.mocks.dart';
void main() { void main() {
@@ -28,8 +26,8 @@ void main() {
late DriftLocalAlbumRepository mockLocalAlbumRepository; late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository; late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late LocalFilesManagerRepository mockLocalFilesManager; late AssetMediaRepository mockAssetMediaRepository;
late StorageRepository mockStorageRepository; late MockPermissionRepository mockPermissionRepository;
late MockNativeSyncApi mockNativeSyncApi; late MockNativeSyncApi mockNativeSyncApi;
late Drift db; late Drift db;
@@ -51,8 +49,8 @@ void main() {
mockLocalAlbumRepository = MockLocalAlbumRepository(); mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository(); mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository(); mockAssetMediaRepository = MockAssetMediaRepository();
mockStorageRepository = MockStorageRepository(); mockPermissionRepository = MockPermissionRepository();
mockNativeSyncApi = MockNativeSyncApi(); mockNativeSyncApi = MockNativeSyncApi();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false); when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
@@ -65,25 +63,28 @@ void main() {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {}); when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {}); when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {}); when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true); when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
return ids;
});
sut = LocalSyncService( sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository, localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository, localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository, trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
localFilesManager: mockLocalFilesManager, assetMediaRepository: mockAssetMediaRepository,
storageRepository: mockStorageRepository, permissionRepository: mockPermissionRepository,
nativeSyncApi: mockNativeSyncApi, nativeSyncApi: mockNativeSyncApi,
); );
await Store.put(StoreKey.manageLocalMediaAndroid, false); await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
}); });
group('LocalSyncService - syncTrashedAssets gating', () { group('LocalSyncService - syncTrashedAssets gating', () {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async { test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true); await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync(); await sut.sync();
@@ -93,7 +94,7 @@ void main() {
test('skips syncTrashedAssets when store flag disabled', () async { test('skips syncTrashedAssets when store flag disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false); await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync(); await sut.sync();
@@ -102,7 +103,7 @@ void main() {
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async { test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true); await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync(); await sut.sync();
@@ -114,7 +115,7 @@ void main() {
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true); await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync(); await sut.sync();
@@ -131,13 +132,13 @@ void main() {
durationMs: 0, durationMs: 0,
orientation: 0, orientation: 0,
isFavorite: false, isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image playbackStyle: PlatformAssetPlaybackStyle.image,
); );
final assetsToRestore = [LocalAssetStub.image1]; final assetsToRestore = [LocalAssetStub.image1];
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore); when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
final restoredIds = ['image1']; final restoredIds = ['image1'];
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>; final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requested, orderedEquals(assetsToRestore)); expect(requested, orderedEquals(assetsToRestore));
return restoredIds; return restoredIds;
@@ -150,10 +151,6 @@ void main() {
}, },
); );
final assetEntity = MockAssetEntity();
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
await sut.processTrashedAssets({ await sut.processTrashedAssets({
'album-a': [platformAsset], 'album-a': [platformAsset],
}); });
@@ -168,12 +165,11 @@ void main() {
expect(trashedEntry.asset.name, platformAsset.name); expect(trashedEntry.asset.name, platformAsset.name);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1); verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1); verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1); verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1); final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>; expect(moveArgs, ['local-trash']);
expect(moveArgs, ['content://local-trash']);
final trashArgs = final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>; as Map<String, List<LocalAsset>>;
@@ -181,6 +177,26 @@ void main() {
expect(trashArgs['album-a'], [localAssetToTrash]); expect(trashArgs['album-a'], [localAssetToTrash]);
}); });
test('records only local assets that were moved to device trash', () async {
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [movedAsset],
'album-b': [skippedAsset],
},
);
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
await sut.processTrashedAssets({});
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [movedAsset]);
});
test('does not attempt restore when repository has no assets to restore', () async { test('does not attempt restore when repository has no assets to restore', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []); when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
@@ -190,7 +206,7 @@ void main() {
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
as Iterable<TrashedAsset>; as Iterable<TrashedAsset>;
expect(trashedSnapshot, isEmpty); expect(trashedSnapshot, isEmpty);
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any())); verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())); verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
}); });
@@ -199,7 +215,7 @@ void main() {
await sut.processTrashedAssets({}); await sut.processTrashedAssets({});
verifyNever(() => mockLocalFilesManager.moveToTrash(any())); verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())); verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
}); });
}); });
@@ -215,7 +231,7 @@ void main() {
isFavorite: false, isFavorite: false,
createdAt: 1700000000, createdAt: 1700000000,
updatedAt: 1732000000, updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image playbackStyle: PlatformAssetPlaybackStyle.image,
); );
final localAsset = platformAsset.toLocalAsset(); final localAsset = platformAsset.toLocalAsset();
@@ -12,12 +12,11 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -26,7 +25,6 @@ import '../../api.mocks.dart';
import '../../fixtures/asset.stub.dart'; import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart'; import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart'; import '../../repository.mocks.dart';
import '../../service.mocks.dart'; import '../../service.mocks.dart';
@@ -52,8 +50,8 @@ void main() {
late SyncApiRepository mockSyncApiRepo; late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo; late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo; late AssetMediaRepository mockAssetMediaRepo;
late StorageRepository mockStorageRepo; late MockPermissionRepository mockPermissionRepo;
late MockApiService mockApi; late MockApiService mockApi;
late MockServerApi mockServerApi; late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo; late MockSyncMigrationRepository mockSyncMigrationRepo;
@@ -86,8 +84,8 @@ void main() {
mockSyncApiRepo = MockSyncApiRepository(); mockSyncApiRepo = MockSyncApiRepository();
mockLocalAssetRepo = MockLocalAssetRepository(); mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository(); mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository(); mockAssetMediaRepo = MockAssetMediaRepository();
mockStorageRepo = MockStorageRepository(); mockPermissionRepo = MockPermissionRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper(); mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper(); mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService(); mockApi = MockApiService();
@@ -159,8 +157,8 @@ void main() {
syncStreamRepository: mockSyncStreamRepo, syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo, localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo, trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo, assetMediaRepository: mockAssetMediaRepo,
storageRepository: mockStorageRepo, permissionRepository: mockPermissionRepo,
api: mockApi, api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo, syncMigrationRepository: mockSyncMigrationRepo,
); );
@@ -170,10 +168,12 @@ void main() {
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []); when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {}); when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
hasManageMediaPermission = false; hasManageMediaPermission = false;
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission); when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true); when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []); final ids = invocation.positionalArguments.first as List<String>;
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null); return ids;
});
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
await Store.put(StoreKey.manageLocalMediaAndroid, false); await Store.put(StoreKey.manageLocalMediaAndroid, false);
}); });
@@ -241,8 +241,8 @@ void main() {
syncStreamRepository: mockSyncStreamRepo, syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo, localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo, trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo, assetMediaRepository: mockAssetMediaRepo,
storageRepository: mockStorageRepo, permissionRepository: mockPermissionRepo,
cancelChecker: cancellationChecker.call, cancelChecker: cancellationChecker.call,
api: mockApi, api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo, syncMigrationRepository: mockSyncMigrationRepo,
@@ -282,8 +282,8 @@ void main() {
syncStreamRepository: mockSyncStreamRepo, syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo, localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo, trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo, assetMediaRepository: mockAssetMediaRepo,
storageRepository: mockStorageRepo, permissionRepository: mockPermissionRepo,
cancelChecker: cancellationChecker.call, cancelChecker: cancellationChecker.call,
api: mockApi, api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo, syncMigrationRepository: mockSyncMigrationRepo,
@@ -424,18 +424,10 @@ void main() {
return assetsByAlbum; return assetsByAlbum;
}); });
final localEntity = MockAssetEntity(); when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only'); final ids = invocation.positionalArguments.first as List<String>;
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity); expect(ids, unorderedEquals(['local-only', 'merged-local']));
return ids;
final mergedEntity = MockAssetEntity();
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
final urls = invocation.positionalArguments.first as List<String>;
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
return true;
}); });
final events = [ final events = [
@@ -461,10 +453,51 @@ void main() {
await simulateEvents(events); await simulateEvents(events);
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1); final trashArgs =
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
expect(trashArgs['album-a'], [localAsset]);
expect(trashArgs['album-b'], [mergedAsset]);
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1); verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
}); });
test("records only assets that were moved to device trash", () async {
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
(_) async => {
'album-a': [movedAsset],
'album-b': [skippedAsset],
},
);
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-moved',
checksum: movedAsset.checksum!,
ack: 'asset-remote-moved',
trashedAt: DateTime(2025, 5, 1),
),
SyncStreamStub.assetTrashed(
id: 'remote-skipped',
checksum: skippedAsset.checksum!,
ack: 'asset-remote-skipped',
trashedAt: DateTime(2025, 5, 2),
),
];
await simulateEvents(events);
final trashArgs =
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [movedAsset]);
});
test("skips device trashing when no local assets match the remote trash payload", () async { test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [ final events = [
SyncStreamStub.assetTrashed( SyncStreamStub.assetTrashed(
@@ -478,7 +511,7 @@ void main() {
await simulateEvents(events); await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())); verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
}); });
@@ -494,7 +527,7 @@ void main() {
await simulateEvents(events); await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1); verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
}); });
@@ -505,7 +538,7 @@ void main() {
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets); when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
final restoredIds = ['trashed-1']; final restoredIds = ['trashed-1'];
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async { when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>; final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requestedAssets, orderedEquals(trashedAssets)); expect(requestedAssets, orderedEquals(trashedAssets));
return restoredIds; return restoredIds;
+3 -3
View File
@@ -3,17 +3,17 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/auth.repository.dart'; import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/domain/services/tag.service.dart'; import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
class MockAssetApiRepository extends Mock implements AssetApiRepository {} class MockAssetApiRepository extends Mock implements AssetApiRepository {}
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {} class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
class MockPermissionRepository extends Mock implements IPermissionRepository {}
class MockAuthApiRepository extends Mock implements AuthApiRepository {} class MockAuthApiRepository extends Mock implements AuthApiRepository {}
class MockAuthRepository extends Mock implements AuthRepository {} class MockAuthRepository extends Mock implements AuthRepository {}
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
class MockTagService extends Mock implements TagService {} class MockTagService extends Mock implements TagService {}
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -3,18 +3,15 @@
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { interface Props {
album: AlbumResponseDto; album: AlbumResponseDto;
}; }
const { album }: Props = $props(); let { album }: Props = $props();
const startDate = album.startDate;
</script> </script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> <span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
{#if startDate} <span>{getAlbumDateRange(album)}</span>
<span>{getAlbumDateRange(startDate, album.endDate ?? startDate)}</span> <span></span>
<span></span>
{/if}
<span>{$t('items_count', { values: { count: album.assetCount } })}</span> <span>{$t('items_count', { values: { count: album.assetCount } })}</span>
</span> </span>
@@ -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>
-6
View File
@@ -34,12 +34,6 @@ export const dateFormats = {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
timeZone: 'UTC',
} satisfies Intl.DateTimeFormatOptions,
albumShort: {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
} satisfies Intl.DateTimeFormatOptions, } satisfies Intl.DateTimeFormatOptions,
settings: { settings: {
month: 'short', month: 'short',
+25 -52
View File
@@ -1,93 +1,66 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { locale } from '$lib/stores/preferences.store';
import { getAlbumDateRange, getShortDateRange } from './date-time'; import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => { describe('getShortDateRange', () => {
beforeEach(() => { beforeEach(() => {
vi.stubEnv('TZ', 'UTC'); vi.stubEnv('TZ', 'UTC');
locale.set('en');
}); });
afterAll(() => { afterAll(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
locale.set('en');
}); });
it('should correctly return long month if start and end date are within the same month', () => { it('should correctly return month if start and end date are within the same month', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022'); expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
}); });
it('should correctly return month range if start and end date are in separate months within the same year', () => { it('should correctly return month range if start and end date are in separate months within the same year', () => {
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('JanFeb 2022'); expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
}); });
it('should correctly return range if start and end date are in separate months and years', () => { it('should correctly return range if start and end date are in separate months and years', () => {
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021Jan 2022'); expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
}); });
it('should correctly return long month if start and end date are within the same month, ignoring local time zone', () => { it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6'); vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022'); expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
}); });
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => { it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6'); vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('JanFeb 2022'); expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
}); });
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => { it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC+6'); vi.stubEnv('TZ', 'UTC+6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021Jan 2022'); expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
});
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
vi.stubEnv('TZ', 'UTC-6');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021  Jan 2022');
});
it('should use the correct locale to return month range', () => {
locale.set('fr');
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('janv.févr. 2022');
});
it('should use the correct locale to return month-year range', () => {
locale.set('fr');
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('déc. 2021  janv. 2022');
}); });
}); });
describe('getAlbumDateRange', () => { describe('getAlbumDate', () => {
beforeAll(() => { beforeAll(() => {
vi.stubEnv('TZ', 'UTC'); process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
}); });
afterAll(() => { it('should work with only a start date', () => {
vi.unstubAllEnvs(); expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00Z' })).toEqual('Jan 1, 2021');
}); });
it('should work', () => { it('should work with a start and end date', () => {
expect(getAlbumDateRange('2021-01-01T00:00:00Z', '2021-01-05T00:00:00Z')).toEqual('Jan 1  5, 2021'); expect(
getAlbumDateRange({
startDate: '2021-01-01T00:00:00Z',
endDate: '2021-01-05T00:00:00Z',
}),
).toEqual('Jan 1, 2021 - Jan 5, 2021');
}); });
it('should work with a single day range', () => { it('should work with the new date format', () => {
expect(getAlbumDateRange('2021-01-01T09:00:00Z', '2021-01-01T10:00:00Z')).toEqual('Jan 1, 2021'); expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021');
});
it('should work with positive time zone present', () => {
expect(getAlbumDateRange('2021-01-01T00:00:00+05:00', '2021-01-01T00:00:00+05:00')).toEqual('Jan 1, 2021');
});
it('should work with negative time zone present', () => {
expect(getAlbumDateRange('2021-01-01T00:00:00-05:00', '2021-01-01T00:00:00-05:00')).toEqual('Jan 1, 2021');
});
it('should use the proper locale', () => {
locale.set('fr');
expect(getAlbumDateRange('2020-03-26T12:00:00Z', '2021-12-01T00:00:00Z')).toEqual('26 mars 2020  1 déc. 2021');
locale.set('en');
}); });
}); });
+56 -15
View File
@@ -7,28 +7,69 @@ export function parseUtcDate(date: string) {
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC(); return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
} }
const getDateRange = (startTimestamp: string, endTimestamp: string, format: 'short' | 'long') => { export const getShortDateRange = (startTimestamp: string, endTimestamp: string) => {
const userLocale = get(locale); const userLocale = get(locale);
const startDate = DateTime.fromISO(startTimestamp).setZone('UTC'); let startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
const endDate = DateTime.fromISO(endTimestamp).setZone('UTC').setLocale(userLocale); let endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
if (startDate.year === endDate.year && startDate.month === endDate.month && format === 'short') { if (userLocale) {
return endDate.toLocaleString({ month: 'long', year: 'numeric' }); startDate = startDate.setLocale(userLocale);
endDate = endDate.setLocale(userLocale);
} }
const formatter = new Intl.DateTimeFormat( const endDateLocalized = endDate.toLocaleString({
userLocale, month: 'short',
format === 'short' ? dateFormats.albumShort : dateFormats.album, year: 'numeric',
); });
return formatter.formatRange(startDate.toJSDate(), endDate.toJSDate());
if (startDate.year === endDate.year) {
if (startDate.month === endDate.month) {
// Same year and month.
// e.g.: aug. 2024
return endDateLocalized;
} else {
// Same year but different month.
// e.g.: jul. - sept. 2024
const startMonthLocalized = startDate.toLocaleString({
month: 'short',
});
return `${startMonthLocalized} - ${endDateLocalized}`;
}
} else {
// Different year.
// e.g.: feb. 2021 - sept. 2024
const startDateLocalized = startDate.toLocaleString({
month: 'short',
year: 'numeric',
});
return `${startDateLocalized} - ${endDateLocalized}`;
}
}; };
/** const formatDate = (date?: string) => {
* Get localized date range in short format like 'Oct Nov 2026', with full month if start and end are the same: 'October 2026' if (!date) {
*/ return;
export const getShortDateRange = (start: string, end: string) => getDateRange(start, end, 'short'); }
export const getAlbumDateRange = (start: string, end: string) => getDateRange(start, end, 'long'); // without timezone
const localDate = date.replace(/Z$/, '').replace(/\+.+$/, '');
return localDate ? new Date(localDate).toLocaleDateString(get(locale), dateFormats.album) : undefined;
};
export const getAlbumDateRange = (album: { startDate?: string; endDate?: string }) => {
const start = formatDate(album.startDate);
const end = formatDate(album.endDate);
if (start && end && start !== end) {
return `${start} - ${end}`;
}
if (start) {
return start;
}
return '';
};
/** /**
* Use this to convert from "5pm EST" to "5pm UTC" * Use this to convert from "5pm EST" to "5pm UTC"
@@ -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)]"
> >
@@ -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 } })}
@@ -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>