Compare commits

..

2 Commits

Author SHA1 Message Date
renovate[bot] ca2f72687b chore(deps): update dependency @types/node to ^24.12.4 2026-05-19 14:18:50 +00:00
immich-tofu[bot] c28e5f90b6 chore: modify .github/workflows/org-zizmor.yml 2026-05-19 10:45:23 +00:00
69 changed files with 859 additions and 19045 deletions
+1
View File
@@ -13,3 +13,4 @@ jobs:
actions: read
contents: read
security-events: write
secrets: inherit
+1 -1
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
+2 -15
View File
@@ -465,14 +465,10 @@
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
"advanced_settings_readonly_mode_title": "Read-only mode",
"advanced_settings_review_remote_deletions_subtitle": "Manually review cloud trash changes. Restorations are applied automatically.",
"advanced_settings_review_remote_deletions_title": "Review remote deletions",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_off_subtitle": "Cloud trash changes are ignored",
"advanced_settings_sync_remote_deletions_selector_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically move assets to trash or restore them on this device when that action is taken on the web.",
"advanced_settings_sync_remote_deletions_title": "Auto sync",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
@@ -583,11 +579,6 @@
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
"asset_offline": "Asset Offline",
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
"asset_out_of_sync_title": "Out-of-sync assets list",
"asset_out_of_sync_trash_confirmation_text": "Move {count, plural, one {asset} other {# assets}} to your device trash?",
"asset_out_of_sync_trash_confirmation_title": "Sync trash change",
"asset_out_of_sync_trash_subtitle": "Assets moved to the Immich cloud trash: choose to move them to local trash or keep them on this device.",
"asset_out_of_sync_trash_subtitle_result": "Nothing left to review — all decisions applied.",
"asset_restored_successfully": "Asset restored successfully",
"asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
@@ -606,7 +597,6 @@
"assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
"assets_denied_to_moved_to_trash_count": "Keeping local copies of {count, plural, one {# asset} other {# assets}}",
"assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
"assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
@@ -1376,7 +1366,6 @@
"keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites",
"keep_in_trash": "Keep in trash",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep on this device",
"keep_this_delete_others": "Keep this, delete others",
@@ -1696,7 +1685,6 @@
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",
"off": "Off",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
@@ -1971,7 +1959,6 @@
"retry_upload": "Retry upload",
"review_duplicates": "Review duplicates",
"review_large_files": "Review large files",
"review_out_of_sync_changes": "Review out-of-sync changes",
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",
@@ -1,181 +0,0 @@
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 android.provider.Settings
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
private const val TAG = "MediaTrashDelegate"
class MediaTrashDelegate(context: Context) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
companion object {
private const val PERMISSION_REQUEST_CODE = 1001
private const val TRASH_REQUEST_CODE = 1002
}
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()
}
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
}
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
}
restoreUris(listOf(ContentUris.withAppendedId(contentUriForType(type.toInt()), id)), callback)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreUris(uris: List<Uri>, callback: (Result<Boolean>) -> Unit) {
if (uris.isEmpty()) {
callback(Result.failure(FlutterError("TRASH_ERROR", "No URIs to restore", null)))
return
}
toggleTrash(uris, false, callback)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun toggleTrash(
contentUris: List<Uri>,
isTrashed: Boolean,
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, contentUris, isTrashed)
pendingResult = callback
activity.startIntentSenderForResult(
pendingIntent.intentSender,
TRASH_REQUEST_CODE,
null,
0,
0,
0
)
} catch (e: Exception) {
Log.e(TAG, "Error creating or starting trash request", e)
callback(Result.failure(FlutterError("TRASH_ERROR", "Error creating or starting trash request", null)))
}
}
@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() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) {
pendingResult?.invoke(Result.success(hasManageMediaPermission()))
pendingResult = null
return true
}
if (requestCode == TRASH_REQUEST_CODE) {
pendingResult?.invoke(Result.success(resultCode == Activity.RESULT_OK))
pendingResult = null
return true
}
return false
}
}
@@ -553,10 +553,6 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object {
@@ -751,78 +747,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasManageMediaPermission())
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.requestManageMediaPermission{ 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 {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.manageMediaPermission{ 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 {
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 {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
@@ -17,8 +17,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
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.Dispatchers
import kotlinx.coroutines.Job
@@ -41,11 +39,10 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -451,36 +448,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null
}
fun hasManageMediaPermission(): Boolean = mediaTrashDelegate.hasManageMediaPermission()
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
}
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.manageMediaPermission { completeWhenActive(callback, it) }
}
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
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
File diff suppressed because it is too large Load Diff
-69
View File
@@ -537,10 +537,6 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
@@ -725,71 +721,6 @@ class NativeSyncApiSetup {
} else {
getTrashedAssetsChannel.setMessageHandler(nil)
}
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.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.NativeSyncApi.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.NativeSyncApi.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)
}
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
? 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)
-16
View File
@@ -382,22 +382,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
func hasManageMediaPermission() throws -> Bool {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
}
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.failure(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(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album
@@ -23,7 +23,6 @@ class RemoteAsset extends BaseAsset {
required super.createdAt,
required super.updatedAt,
this.uploadedAt,
this.deletedAt,
super.width,
super.height,
super.durationMs,
@@ -33,6 +32,7 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
}) : localAssetId = localId;
@override
@@ -48,7 +48,7 @@ class RemoteAsset extends BaseAsset {
String get heroTag => '${localId ?? checksum}_$id';
@override
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage && deletedAt == null;
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
bool get isTrashed => deletedAt != null;
@@ -62,7 +62,6 @@ class RemoteAsset extends BaseAsset {
createdAt: $createdAt,
updatedAt: $updatedAt,
uploadedAt: ${uploadedAt ?? "<NA>"},
deletedAt: ${deletedAt ?? "<NA>"},
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationMs: ${durationMs ?? "<NA>"},
@@ -90,7 +89,6 @@ class RemoteAsset extends BaseAsset {
ownerId == other.ownerId &&
thumbHash == other.thumbHash &&
visibility == other.visibility &&
deletedAt == other.deletedAt &&
stackId == other.stackId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
@@ -104,7 +102,6 @@ class RemoteAsset extends BaseAsset {
localId.hashCode ^
thumbHash.hashCode ^
visibility.hashCode ^
deletedAt.hashCode ^
stackId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
@@ -119,7 +116,6 @@ class RemoteAsset extends BaseAsset {
DateTime? createdAt,
DateTime? updatedAt,
DateTime? uploadedAt,
DateTime? deletedAt,
int? width,
int? height,
int? durationMs,
@@ -129,6 +125,7 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -140,7 +137,6 @@ class RemoteAsset extends BaseAsset {
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width,
height: height ?? this.height,
durationMs: durationMs ?? this.durationMs,
@@ -150,6 +146,7 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}
@@ -1,8 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class RemoteDeletedLocalAsset {
final LocalAsset asset;
final DateTime remoteDeletedAt;
const RemoteDeletedLocalAsset({required this.asset, required this.remoteDeletedAt});
}
@@ -18,9 +18,6 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
reviewOutOfSyncChangesAndroid<bool>._(1014),
trashSyncLastCleanup<int>._(1015),
// Legacy keys that have been migrated to the new metadata store
legacyBackupRequireCharging<bool>._(7),
legacyBackupTriggerDelay<int>._(8),
@@ -4,12 +4,15 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.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_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/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
@@ -20,31 +23,34 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required DriftTrashSyncRepository trashSyncRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_trashSyncRepository = trashSyncRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid) {
await _syncTrashedAssets();
// Catch-up restore for app-trashed assets whose remote came
// alive but for which we never received (or never acted on)
// the sync event. Android-only; iOS catches restores only on
// the event path (no programmatic restore available there).
await _trashSyncRepository.syncRestoresForRevivedAssets();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
if (CurrentPlatform.isIOS) {
@@ -54,11 +60,7 @@ class LocalSyncService {
if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}");
await fullSync();
if (CurrentPlatform.isAndroid) {
await _cleanupTrashSync();
}
return;
return await fullSync();
}
final delta = await _nativeSyncApi.getMediaChanges();
@@ -80,15 +82,13 @@ class LocalSyncService {
);
final dbAlbums = await _localAlbumRepository.getAll();
// On Android, we need to sync all albums since it is not possible to
// detect album deletions from the native side
if (CurrentPlatform.isAndroid) {
// On Android, we need to sync all albums since it is not possible to
// detect album deletions from the native side
for (final album in dbAlbums) {
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
}
await _cleanupTrashSync();
}
if (CurrentPlatform.isIOS) {
@@ -115,13 +115,6 @@ class LocalSyncService {
}
}
Future<void> _cleanupTrashSync() async {
final deleted = await _trashSyncRepository.cleanup();
if (deleted > 0) {
_log.info("cleanup TrashState, deleted: $deleted");
}
}
Future<void> fullSync() async {
try {
final Stopwatch stopwatch = Stopwatch()..start();
@@ -366,10 +359,6 @@ class LocalSyncService {
await processTrashedAssets(trashedAssetMap);
}
/// Android-only OS-trash mirror reconciliation. The decision-state
/// table (`trash_sync`) and the restore branch both live
/// in `DriftTrashSyncRepository`; this method just keeps
/// `trashed_local_asset` in sync with the OS trash snapshot.
@visibleForTesting
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
if (trashedAssetMap.isEmpty) {
@@ -381,6 +370,30 @@ class LocalSyncService {
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
}
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
}
}
}
@@ -3,14 +3,18 @@
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/entities/store.entity.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/storage.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_stream.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/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -23,14 +27,15 @@ enum SyncMigrationTask {
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
}
typedef _RemoteAssetTrashState = ({String id, DateTime? deletedAt, String? checksum});
class SyncStreamService {
final Logger _logger = Logger('SyncStreamService');
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
@@ -38,13 +43,19 @@ class SyncStreamService {
SyncStreamService({
required SyncApiRepository syncApiRepository,
required SyncStreamRepository syncStreamRepository,
required DriftTrashSyncRepository trashSyncRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
_trashSyncRepository = trashSyncRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
@@ -189,24 +200,22 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
await _handleRemoteAssetTrashState(
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetV2:
final remoteSyncAssets = data.cast<SyncAssetV2>();
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
await _handleRemoteAssetTrashState(
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
final now = DateTime.now();
final remoteDeletedAtByRemoteId = Map<String, DateTime>.fromEntries(
remoteSyncAssets.map((e) => MapEntry(e.assetId, now)),
);
await _trashSyncRepository.recordRemoteTrash(remoteDeletedAtByRemoteId);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -477,26 +486,58 @@ class SyncStreamService {
}
}
/// Funnels remote asset events into the trash-state machine.
///
/// `deletedAt != null` → trash event (Android/iOS) handled by service.
/// `deletedAt == null` → potential restore event for any state row
/// keyed by that checksum.
Future<void> _handleRemoteAssetTrashState(Iterable<_RemoteAssetTrashState> remoteSyncAssets) async {
if (!CurrentPlatform.isAndroid && !CurrentPlatform.isIOS) {
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
if (localAssetsToTrash.isNotEmpty) {
await _trashLocalAssets(localAssetsToTrash);
} else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
}
}
}
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
final deleted = <String, DateTime>{};
final aliveChecksums = <String>[];
for (final e in remoteSyncAssets) {
if (e.deletedAt != null) {
deleted[e.id] = e.deletedAt!;
} else if (e.checksum != null) {
aliveChecksums.add(e.checksum!);
}
await _handleRemoteDeleted(remoteIds);
await _applyRemoteRestoreToLocal();
}
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await _trashSyncRepository.recordRemoteTrash(deleted);
await _trashSyncRepository.recordRemoteRestore(aliveChecksums);
await _handleRemoteDeleted(remoteIds);
}
}
@@ -35,7 +35,6 @@ enum TimelineOrigin {
albumActivities,
folder,
recentlyAdded,
syncTrash,
}
class TimelineFactory {
@@ -70,8 +69,6 @@ class TimelineFactory {
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
TimelineService toTrashSyncReview() => TimelineService(_timelineRepository.toTrashSyncReview(groupBy));
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));
@@ -1,73 +0,0 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
/// What the user (or auto-mode) decided about this candidate.
enum TrashStateDecision {
// do not change this order!
pendingReview,
kept,
appTrashed,
}
/// Why this row was created. Drives restore behaviour.
enum TrashTriggerSource {
// do not change this order!
remoteSync,
localUser,
}
/// Single source of truth for "what did we decide to do with this local asset?".
///
/// Replaces `trash_sync_entity` and the operational-journal aspect of
/// `trashed_local_asset_entity`. The OS-trash *mirror* role of
/// `trashed_local_asset_entity` (Android `source = localSync` rows)
/// stays separate.
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_decision ON trash_sync_entity (decision)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync_entity (checksum)')
class TrashSyncEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const TrashSyncEntity();
TextColumn get localAssetId => text()();
TextColumn get checksum => text().nullable()();
IntColumn get decision => intEnum<TrashStateDecision>()();
IntColumn get triggerSource => intEnum<TrashTriggerSource>()();
TextColumn get albumId => text().nullable()();
DateTimeColumn get remoteDeletedAt => dateTime().nullable()();
DateTimeColumn get decidedAt => dateTime().withDefault(currentDateAndTime)();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
IntColumn get orientation => integer().withDefault(const Constant(0))();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {localAssetId};
}
extension TrashSyncEntityDataDomainExtension on TrashSyncEntityData {
LocalAsset toLocalAsset() => LocalAsset(
id: localAssetId,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationMs: durationMs,
isFavorite: isFavorite,
height: height,
width: width,
orientation: orientation,
playbackStyle: playbackStyle,
isEdited: false,
);
}
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
@@ -57,7 +56,6 @@ import 'package:logging/logging.dart';
TrashedLocalAssetEntity,
AssetEditEntity,
MetadataEntity,
TrashSyncEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -100,7 +98,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 27;
int get schemaVersion => 26;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -278,15 +276,6 @@ class Drift extends $Drift {
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
// Trash-sync decision table — keyed by local_asset_id with
// an explicit decision enum (pendingReview / kept /
// appTrashed). Defined fresh in v27 because feat/review-page
// hasn't shipped — no prior trash_sync rows exist in the wild.
await m.create(v27.trashSyncEntity);
await m.createIndex(v27.idxTrashSyncDecision);
await m.createIndex(v27.idxTrashSyncChecksum);
},
),
);
@@ -45,11 +45,9 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
as i21;
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
as i22;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i23;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
as i23;
import 'package:drift/internal/modular.dart' as i24;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -96,11 +94,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
this,
);
late final i23.$TrashSyncEntityTable trashSyncEntity = i23
.$TrashSyncEntityTable(this);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
this,
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -137,7 +133,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
trashedLocalAssetEntity,
assetEditEntity,
metadataEntity,
trashSyncEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@@ -150,8 +145,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId,
i23.idxTrashSyncDecision,
i23.idxTrashSyncChecksum,
];
@override
i0.StreamQueryUpdateRules
@@ -404,6 +397,4 @@ class $DriftManager {
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$MetadataEntityTableTableManager get metadataEntity =>
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
i23.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
i23.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
}
@@ -13539,677 +13539,6 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
final class Schema27 extends i0.VersionedSchema {
Schema27({required super.database}) : super(version: 27);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
metadata,
trashSyncEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxTrashSyncDecision,
idxTrashSyncChecksum,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 metadata = Shape49(
source: i0.VersionedTable(
entityName: 'metadata',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 trashSyncEntity = Shape51(
source: i0.VersionedTable(
entityName: 'trash_sync_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(local_asset_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_213,
_column_131,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_120,
_column_132,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxTrashSyncDecision = i1.Index(
'idx_trash_sync_decision',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_decision ON trash_sync_entity (decision)',
);
final i1.Index idxTrashSyncChecksum = i1.Index(
'idx_trash_sync_checksum',
'CREATE INDEX IF NOT EXISTS idx_trash_sync_checksum ON trash_sync_entity (checksum)',
);
}
class Shape51 extends i0.VersionedTable {
Shape51({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get localAssetId =>
columnsByName['local_asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get decision =>
columnsByName['decision']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get triggerSource =>
columnsByName['trigger_source']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get remoteDeletedAt =>
columnsByName['remote_deleted_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get decidedAt =>
columnsByName['decided_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<String> _column_213(String aliasedName) =>
i1.GeneratedColumn<String>(
'local_asset_id',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_214(String aliasedName) =>
i1.GeneratedColumn<int>(
'decision',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_215(String aliasedName) =>
i1.GeneratedColumn<int>(
'trigger_source',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_216(String aliasedName) =>
i1.GeneratedColumn<String>(
'album_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<String> _column_217(String aliasedName) =>
i1.GeneratedColumn<String>(
'remote_deleted_at',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<String> _column_218(String aliasedName) =>
i1.GeneratedColumn<String>(
'decided_at',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL DEFAULT CURRENT_TIMESTAMP',
defaultValue: const i1.CustomExpression('CURRENT_TIMESTAMP'),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -14236,7 +13565,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -14365,11 +13693,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from25To26(migrator, schema);
return 26;
case 26:
final schema = Schema27(database: database);
final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema);
return 27;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -14402,7 +13725,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -14430,6 +13752,5 @@ i1.OnUpgrade stepByStep({
from23To24: from23To24,
from24To25: from24To25,
from25To26: from25To26,
from26To27: from26To27,
),
);
@@ -6,7 +6,6 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
@@ -110,52 +109,37 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
/// Look up local assets in backup-selected albums whose checksum matches
/// a remote-deleted id, excluding those that already have a recorded
/// decision in `trash_sync`.
Future<Map<String, List<RemoteDeletedLocalAsset>>> getRemoteTrashCandidatesByAlbum(
Map<String, DateTime> remoteDeletedAtByRemoteId,
) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return {};
}
final result = <String, List<RemoteDeletedLocalAsset>>{};
final result = <String, List<LocalAsset>>{};
for (final slice in remoteDeletedAtByRemoteId.keys.toSet().slices(kDriftMaxChunk)) {
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
leftOuterJoin(
_db.trashSyncEntity,
_db.localAssetEntity.id.equalsExp(_db.trashSyncEntity.localAssetId),
useColumns: false,
),
])
..addColumns([_db.remoteAssetEntity.id])
..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice) &
_db.trashSyncEntity.localAssetId.isNull(),
))
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final assetData = row.readTable(_db.localAssetEntity);
final remoteId = row.read(_db.remoteAssetEntity.id)!;
(result[albumId] ??= <RemoteDeletedLocalAsset>[]).add(
RemoteDeletedLocalAsset(
asset: assetData.toDto(remoteId: remoteId),
remoteDeletedAt: remoteDeletedAtByRemoteId[remoteId]!,
),
);
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
@@ -230,23 +214,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
/// Bulk-fetch `LocalAsset` snapshots for a set of ids. Used by
/// `DriftTrashSyncRepository.recordUserManualTrash` to capture the asset's
/// metadata before the `local_asset` row is deleted.
Future<List<LocalAsset>> getByIds(Iterable<String> ids) async {
final input = ids.toSet();
if (input.isEmpty) {
return const [];
}
final out = <LocalAsset>[];
for (final slice in input.slices(kDriftMaxChunk)) {
final query = _db.localAssetEntity.select()..where((row) => row.id.isIn(slice));
final rows = await query.map((row) => row.toDto()).get();
out.addAll(rows);
}
return out;
}
Future<void> reconcileHashesFromCloudId() async {
await _db.customUpdate(
'''
@@ -84,14 +84,6 @@ class StorageRepository {
return entity;
}
Future<String?> getMediaUrlForAsset(LocalAsset asset) async {
final entity = await getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
return entity.getMediaUrl();
}
Future<bool> isAssetAvailableLocally(String assetId) async {
try {
final entity = await AssetEntity.fromId(assetId);
@@ -4,13 +4,10 @@ import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -349,12 +346,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
joinLocal: true,
);
TimelineQuery toTrashSyncReview(GroupAssetsBy groupBy) => (
bucketSource: () => _watchTrashSyncBucket(groupBy: groupBy),
assetSource: (offset, count) => _getToTrashSyncBucketAssets(offset: offset, count: count),
origin: TimelineOrigin.syncTrash,
);
TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
@@ -687,82 +678,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
Stream<List<Bucket>> _watchTrashSyncBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
// TODO: implement GroupAssetBy for place
throw UnsupportedError("GroupAssetsBy.none is not supported for watchPlaceBucket");
}
return _watchTrashSyncLocalAssets().map((assets) {
final bucketCounts = <DateTime, int>{};
for (final asset in assets) {
final localTime = asset.createdAt.toLocal();
final bucketDate = switch (groupBy) {
GroupAssetsBy.day || GroupAssetsBy.auto => DateTime(localTime.year, localTime.month, localTime.day),
GroupAssetsBy.month => DateTime(localTime.year, localTime.month),
GroupAssetsBy.none => throw ArgumentError("GroupAssetsBy.none is not supported for date formatting"),
};
bucketCounts[bucketDate] = (bucketCounts[bucketDate] ?? 0) + 1;
}
return bucketCounts.entries.map((entry) => TimeBucket(date: entry.key, assetCount: entry.value)).toList();
});
}
Future<List<BaseAsset>> _getToTrashSyncBucketAssets({required int offset, required int count}) async {
return _getTrashSyncLocalAssets(offset: offset, count: count);
}
Stream<List<LocalAsset>> _watchTrashSyncLocalAssets() {
return _trashSyncLocalAssetsQuery().watch().map((rows) => rows.map((row) => row.toDto()).toList(growable: false));
}
Future<List<LocalAsset>> _getTrashSyncLocalAssets({required int offset, required int count}) {
return _trashSyncLocalAssetsQuery(
offset: offset,
count: count,
).get().then((rows) => rows.map((row) => row.toDto()).toList(growable: false));
}
SimpleSelectStatement<$LocalAssetEntityTable, LocalAssetEntityData> _trashSyncLocalAssetsQuery({
int? offset,
int? count,
}) {
// Pending-review asset ids whose underlying local_asset is still in
// a backup-selected album. The state table is keyed by
// local_asset_id, so no checksum → id resolution is needed.
final selectedAlbumAssets =
_db.localAlbumAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.assetId.equalsExp(_db.trashSyncEntity.localAssetId) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
final pendingAssetIds = _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.localAssetId])
..where(
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) & existsQuery(selectedAlbumAssets),
);
final query = _db.localAssetEntity.select()
..where((row) => row.id.isInQuery(pendingAssetIds))
..orderBy([(row) => OrderingTerm.desc(row.createdAt), (row) => OrderingTerm.asc(row.id)]);
if (count != null) {
query.limit(count, offset: offset);
}
return query;
}
}
List<Bucket> _generateBuckets(int count) {
@@ -1,533 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:logging/logging.dart';
/// Mode for handling remote-delete events on this platform.
enum TrashSyncMode { off, autoSync, review }
/// Result of a user-driven trash decision (Keep / Move to trash).
typedef RemoteTrashResolveResult = ({int displayCount, bool success});
/// Outcome emitted on the repository's `restoreOutcomes` stream when a
/// remote-restore is processed. The UI layer subscribes and surfaces
/// notifications — particularly important on iOS, where the user must
/// manually recover assets from Recently Deleted.
class RestoreOutcome {
final List<String> restoredAssetIds;
final List<String> needsManualRestoreOnIos;
const RestoreOutcome({required this.restoredAssetIds, required this.needsManualRestoreOnIos});
}
class TrashSyncCandidate {
final String localAssetId;
final String? checksum;
final String? albumId;
final DateTime? remoteDeletedAt;
final TrashTriggerSource triggerSource;
final String name;
final AssetType type;
final DateTime createdAt;
final DateTime updatedAt;
final int? width;
final int? height;
final int? durationMs;
final bool isFavorite;
final int orientation;
final AssetPlaybackStyle playbackStyle;
const TrashSyncCandidate({
required this.localAssetId,
required this.checksum,
required this.albumId,
required this.remoteDeletedAt,
required this.triggerSource,
required this.name,
required this.type,
required this.createdAt,
required this.updatedAt,
required this.width,
required this.height,
required this.durationMs,
required this.isFavorite,
required this.orientation,
required this.playbackStyle,
});
}
/// Owner of the trash-sync state machine.
///
/// Follows the codebase convention of rich repositories (see
/// `local_album.repository.dart`): owns transactional table writes plus
/// the OS-side trash/restore calls needed to keep the local file system
/// in sync with decisions recorded in `trash_sync_entity`.
///
/// **Every public method that mutates state is one transaction** —
/// the cross-repo atomicity gap from the original PR cannot recur.
class DriftTrashSyncRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftTrashSyncRepository');
final Drift _db;
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final StreamController<RestoreOutcome> _restoreOutcomes = StreamController.broadcast();
DriftTrashSyncRepository(this._db, this._localAssetRepository, this._assetMediaRepository) : super(_db);
/// Subscribe to restore-outcome events. iOS callers should surface
/// `needsManualRestoreOnIos` lists as user-facing notifications
/// ("recover from Recently Deleted in Photos").
Stream<RestoreOutcome> get restoreOutcomes => _restoreOutcomes.stream;
void dispose() {
_restoreOutcomes.close();
}
TrashSyncMode get mode {
if (Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false)) {
return TrashSyncMode.review;
}
if (Store.get(StoreKey.manageLocalMediaAndroid, false)) {
return TrashSyncMode.autoSync;
}
return TrashSyncMode.off;
}
// ===================================================================
// State machine: remote events
// ===================================================================
/// A remote asset was marked as deleted/trashed. Find matching local
/// assets in backup-selected albums and either auto-trash them (auto
/// mode + permission) or queue them for review.
Future<void> recordRemoteTrash(Map<String, DateTime> remoteDeletedAtByRemoteId) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
return;
}
final currentMode = mode;
if (currentMode == TrashSyncMode.off) {
return;
}
final candidatesByAlbum = await _localAssetRepository.getRemoteTrashCandidatesByAlbum(remoteDeletedAtByRemoteId);
if (candidatesByAlbum.isEmpty) {
_logger.fine('No local assets matched remote-delete batch of ${remoteDeletedAtByRemoteId.length}');
return;
}
// Dedupe by local asset id — one state row per asset, regardless of
// how many backup-selected albums it appears in.
final uniqueById = <String, ({String albumId, RemoteDeletedLocalAsset candidate})>{};
for (final entry in candidatesByAlbum.entries) {
for (final candidate in entry.value) {
uniqueById.putIfAbsent(candidate.asset.id, () => (albumId: entry.key, candidate: candidate));
}
}
if (currentMode == TrashSyncMode.autoSync && await _canMoveLocalMediaToTrash()) {
final ids = uniqueById.keys.toList();
_logger.info('Auto-trashing ${ids.length} local assets');
final movedIds = (await _assetMediaRepository.deleteAll(ids)).toSet();
final newCandidates = uniqueById.values.map((item) => _candidateFrom(item.albumId, item.candidate)).toList();
await upsertCandidates(newCandidates);
if (movedIds.isNotEmpty) {
await markDecision(movedIds, TrashStateDecision.appTrashed);
}
return;
}
final newCandidates = uniqueById.values.map((item) => _candidateFrom(item.albumId, item.candidate)).toList();
await upsertCandidates(newCandidates);
}
/// A remote asset is alive again. Drop pending/kept rows for the same
/// checksum; on Android, restore `appTrashed` rows via the OS. On iOS
/// emit a notification for the user to do it manually.
Future<void> recordRemoteRestore(Iterable<String> aliveRemoteChecksums) async {
// Check permission BEFORE deleting state rows so a denied permission
// doesn't silently drop in-flight restore intents.
if (CurrentPlatform.isAndroid && !await _hasManageMediaPermission('restore from trash')) {
return;
}
final affected = await deleteForRestoredRemotes(aliveRemoteChecksums);
if (affected.isEmpty) {
return;
}
final wereAppTrashed = affected.where((r) => r.decision == TrashStateDecision.appTrashed).toList();
if (wereAppTrashed.isEmpty) {
return;
}
if (CurrentPlatform.isAndroid) {
final localAssets = wereAppTrashed.map((r) => r.toLocalAsset()).toList();
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(localAssets);
_restoreOutcomes.add(RestoreOutcome(restoredAssetIds: restoredIds, needsManualRestoreOnIos: const []));
} else if (CurrentPlatform.isIOS) {
_restoreOutcomes.add(
RestoreOutcome(
restoredAssetIds: const [],
needsManualRestoreOnIos: wereAppTrashed.map((r) => r.localAssetId).toList(),
),
);
}
}
/// Periodic catch-up: restore app-trashed assets whose remote came
/// alive but for which we never received (or never acted on) the
/// matching sync event. Complements [recordRemoteRestore], which is
/// event-driven.
///
/// **Android-only.** iOS can't programmatically restore, so a periodic
/// catch-up would either spam the same notification every sync or
/// require extra "already notified" state.
Future<void> syncRestoresForRevivedAssets() async {
if (!CurrentPlatform.isAndroid) {
return;
}
if (!await _hasManageMediaPermission('restore from trash')) {
return;
}
final rows = await getAppTrashedRemotelyRestored();
if (rows.isEmpty) {
return;
}
final localAssets = rows.map((r) => r.toLocalAsset()).toList();
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(localAssets);
if (restoredIds.isEmpty) {
return;
}
// Drop rows we successfully restored. Anything we couldn't restore
// stays for next sync, or gets swept by `cleanup()` rule 1.
await deleteByAssetIds(restoredIds);
_restoreOutcomes.add(RestoreOutcome(restoredAssetIds: restoredIds, needsManualRestoreOnIos: const []));
}
// ===================================================================
// State machine: user actions
// ===================================================================
/// Apply a user review decision. The HIGH atomicity bug from the
/// original PR is structurally impossible because both writes are
/// single-row column updates on the same table.
Future<RemoteTrashResolveResult> applyReviewDecision(Iterable<String> localAssetIds, {required bool keep}) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return (displayCount: 0, success: true);
}
if (keep) {
await markDecision(ids, TrashStateDecision.kept);
return (displayCount: ids.length, success: true);
}
final movedIds = (await _assetMediaRepository.deleteAll(ids.toList())).toSet();
if (movedIds.isEmpty) {
return (displayCount: 0, success: false);
}
await markDecision(movedIds, TrashStateDecision.appTrashed);
return (displayCount: movedIds.length, success: movedIds.length == ids.length);
}
/// Record an asset the user manually trashed inside the app. Replaces
/// the old `applyTrashedAssets` journal write on `trashed_local_asset`.
Future<void> recordUserManualTrash(Iterable<String> localAssetIds) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return;
}
final snapshots = await _localAssetRepository.getByIds(ids);
if (snapshots.isEmpty) {
return;
}
final manualCandidates = snapshots
.map(
(a) => TrashSyncCandidate(
localAssetId: a.id,
checksum: a.checksum,
albumId: null,
remoteDeletedAt: null,
triggerSource: TrashTriggerSource.localUser,
name: a.name,
type: a.type,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
width: a.width,
height: a.height,
durationMs: a.durationMs,
isFavorite: a.isFavorite,
orientation: a.orientation,
playbackStyle: a.playbackStyle,
),
)
.toList();
await upsertCandidates(manualCandidates);
await markDecision(ids, TrashStateDecision.appTrashed);
}
// ===================================================================
// Helpers (private)
// ===================================================================
TrashSyncCandidate _candidateFrom(String albumId, RemoteDeletedLocalAsset candidate) {
final asset = candidate.asset;
return TrashSyncCandidate(
localAssetId: asset.id,
checksum: asset.checksum,
albumId: albumId,
remoteDeletedAt: candidate.remoteDeletedAt,
triggerSource: TrashTriggerSource.remoteSync,
name: asset.name,
type: asset.type,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
width: asset.width,
height: asset.height,
durationMs: asset.durationMs,
isFavorite: asset.isFavorite,
orientation: asset.orientation,
playbackStyle: asset.playbackStyle,
);
}
Future<bool> _canMoveLocalMediaToTrash() async {
if (CurrentPlatform.isAndroid) {
return await _hasManageMediaPermission('move to trash');
}
return true;
}
Future<bool> _hasManageMediaPermission(String logContext) async {
if (!CurrentPlatform.isAndroid) {
return true;
}
final hasPermission = await _assetMediaRepository.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning('$logContext blocked: MANAGE_MEDIA permission missing');
}
return hasPermission;
}
// ===================================================================
// Low-level table writes (used by the state machine above and by
// direct callers/tests).
// ===================================================================
Future<void> upsertCandidates(Iterable<TrashSyncCandidate> candidates) async {
if (candidates.isEmpty) {
return;
}
return _db.batch((batch) {
for (final c in candidates) {
batch.insert(
_db.trashSyncEntity,
TrashSyncEntityCompanion.insert(
localAssetId: c.localAssetId,
checksum: Value(c.checksum),
decision: TrashStateDecision.pendingReview,
triggerSource: c.triggerSource,
albumId: Value(c.albumId),
remoteDeletedAt: Value(c.remoteDeletedAt),
name: c.name,
type: c.type,
createdAt: Value(c.createdAt),
updatedAt: Value(c.updatedAt),
width: Value(c.width),
height: Value(c.height),
durationMs: Value(c.durationMs),
isFavorite: Value(c.isFavorite),
orientation: Value(c.orientation),
playbackStyle: Value(c.playbackStyle),
),
// Existing rows already have a decision (pending or terminal).
// Don't overwrite — the prior decision still applies.
mode: InsertMode.insertOrIgnore,
);
}
});
}
Future<void> markDecision(Iterable<String> localAssetIds, TrashStateDecision decision) {
assert(decision != TrashStateDecision.pendingReview, 'Use upsertCandidates for pending rows');
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final slice in ids.slices(kDriftMaxChunk)) {
batch.update(
_db.trashSyncEntity,
TrashSyncEntityCompanion(decision: Value(decision), decidedAt: Value(DateTime.now())),
where: (tbl) => tbl.localAssetId.isIn(slice),
);
}
});
}
/// Atomically read-then-delete rows for restored remotes. Caller
/// decides whether to trigger an OS restore (Android) or surface a
/// notification (iOS).
Future<List<TrashSyncEntityData>> deleteForRestoredRemotes(Iterable<String> remoteAliveChecksums) {
final checksums = remoteAliveChecksums.toSet();
if (checksums.isEmpty) {
return Future.value(const []);
}
return _db.transaction(() async {
final affected = <TrashSyncEntityData>[];
for (final slice in checksums.slices(kDriftMaxChunk)) {
final rows = await (_db.select(
_db.trashSyncEntity,
)..where((t) => t.checksum.isIn(slice) & t.triggerSource.equalsValue(TrashTriggerSource.remoteSync))).get();
affected.addAll(rows);
}
for (final slice in checksums.slices(kDriftMaxChunk)) {
await (_db.delete(
_db.trashSyncEntity,
)..where((t) => t.checksum.isIn(slice) & t.triggerSource.equalsValue(TrashTriggerSource.remoteSync))).go();
}
return affected;
});
}
Future<void> deleteByAssetIds(Iterable<String> localAssetIds) {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final slice in ids.slices(kDriftMaxChunk)) {
batch.deleteWhere(_db.trashSyncEntity, (t) => t.localAssetId.isIn(slice));
}
});
}
Future<List<TrashSyncEntityData>> getAppTrashedRemotelyRestored() async {
final selectedAlbumIds = _db.selectOnly(_db.localAlbumEntity)
..addColumns([_db.localAlbumEntity.id])
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected));
final rows =
await (_db.select(_db.trashSyncEntity).join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.checksum.equalsExp(_db.trashSyncEntity.checksum)),
])..where(
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.appTrashed) &
_db.trashSyncEntity.triggerSource.equalsValue(TrashTriggerSource.remoteSync) &
_db.remoteAssetEntity.deletedAt.isNull() &
(_db.trashSyncEntity.albumId.isInQuery(selectedAlbumIds) | _db.trashSyncEntity.albumId.isNull()),
))
.get();
return rows.map((r) => r.readTable(_db.trashSyncEntity)).toList();
}
Future<Set<String>> getAppTrashedAssetIds() async {
final rows =
await (_db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.localAssetId])
..where(_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.appTrashed)))
.get();
return rows.map((r) => r.read(_db.trashSyncEntity.localAssetId)!).toSet();
}
Stream<int> watchPendingReviewCount() {
final countExpr = _db.trashSyncEntity.localAssetId.count();
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([countExpr])
..where(
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
_isLocalAssetInBackupSelectedAlbum(),
);
return q.watchSingle().map((row) => row.read(countExpr) ?? 0).distinct();
}
Stream<bool> watchIsAssetPendingById(String localAssetId) {
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.localAssetId])
..where(
_db.trashSyncEntity.localAssetId.equals(localAssetId) &
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
_isLocalAssetInBackupSelectedAlbum(),
)
..limit(1);
return q.watchSingleOrNull().map((row) => row != null).distinct();
}
Stream<bool> watchIsAssetPendingByChecksum(String checksum) {
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.localAssetId])
..where(
_db.trashSyncEntity.checksum.equals(checksum) &
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) &
_isLocalAssetInBackupSelectedAlbum(),
)
..limit(1);
return q.watchSingleOrNull().map((row) => row != null).distinct();
}
Expression<bool> _isLocalAssetInBackupSelectedAlbum() {
final selectedAlbumQ =
_db.localAlbumAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.assetId.equalsExp(_db.trashSyncEntity.localAssetId) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
return existsQuery(selectedAlbumQ);
}
/// Two-rule cleanup, one transaction.
///
/// Rule 1: the remote came back alive (any decision state).
/// Rule 2: the `local_asset` row is gone *and* state != appTrashed.
/// (appTrashed rows are kept because they're needed for restore.)
Future<int> cleanup() async {
return _db.transaction(() async {
final aliveChecksums = _db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.checksum])
..where(_db.remoteAssetEntity.deletedAt.isNull());
final rule1 = await (_db.delete(_db.trashSyncEntity)..where((t) => t.checksum.isInQuery(aliveChecksums))).go();
final liveLocalIds = _db.selectOnly(_db.localAssetEntity)..addColumns([_db.localAssetEntity.id]);
final rule2 =
await (_db.delete(_db.trashSyncEntity)..where(
(t) =>
t.localAssetId.isNotInQuery(liveLocalIds) &
t.decision.equalsValue(TrashStateDecision.appTrashed).not(),
))
.go();
return rule1 + rule2;
});
}
}
@@ -3,7 +3,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
@@ -125,7 +125,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
}
Future<void> trashLocalAssets(Map<String, Iterable<RemoteDeletedLocalAsset>> assetsByAlbums) async {
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
if (assetsByAlbums.isEmpty) {
return Future.value();
}
@@ -134,8 +134,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
final idToDelete = <String>{};
for (final entry in assetsByAlbums.entries) {
for (final record in entry.value) {
final asset = record.asset;
for (final asset in entry.value) {
idToDelete.add(asset.id);
companions.add(
TrashedLocalAssetEntityCompanion(
@@ -265,6 +264,32 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<Map<String, List<LocalAsset>>> getToTrash() async {
final result = <String, List<LocalAsset>>{};
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.deletedAt.isNotNull(),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
return result;
}
//attempt to reuse existing checksums
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
final localChecksumById = <String, String>{};
-76
View File
@@ -654,82 +654,6 @@ class NativeSyncApi {
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
}
Future<bool> hasManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.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.NativeSyncApi.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.NativeSyncApi.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;
}
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 {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
@@ -1,60 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_sync_bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@RoutePage()
class DriftTrashSyncReviewPage extends ConsumerWidget {
const DriftTrashSyncReviewPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access trash');
}
final timelineService = ref.watch(timelineFactoryProvider).toTrashSyncReview();
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: SliverAppBar(
title: Text('asset_out_of_sync_title'.tr()),
floating: true,
snap: true,
pinned: true,
centerTitle: true,
elevation: 0,
),
topSliverWidgetHeight: 24,
topSliverWidget: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 72.0,
child: Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
return outOfSyncCount > 0
? const Text('asset_out_of_sync_trash_subtitle').tr()
: Center(
child: Text('asset_out_of_sync_trash_subtitle_result', style: context.textTheme.bodyLarge).tr(),
);
},
),
),
),
),
bottomSheet: const TrashSyncBottomBar(),
),
);
}
@@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showKeepResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) {
return;
}
final message = result.success
? 'assets_denied_to_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'scaffold_body_error_occurred'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
/// This deny move to trash action has the following behavior:
/// - Deny moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class KeepOnDeviceActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const KeepOnDeviceActionButton({super.key, required this.source, required this.onResult});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
ref.read(assetViewerProvider.notifier).setControls(false);
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, keep: true);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.cloud_off_outlined;
return source == ActionSource.viewer
? BaseActionButton(
maxWidth: 110.0,
iconData: iconData,
label: 'keep'.t(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: const Icon(iconData),
label: Text('keep_on_device'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,98 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
void showTrashResultToast(BuildContext context, ActionResult result) {
if (!context.mounted) {
return;
}
final message = result.success
? 'assets_moved_to_trash_count'.t(args: {'count': '${result.count}'})
: 'errors.something_went_wrong'.t();
ImmichToast.show(
context: context,
msg: message,
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.info : ToastType.error,
);
}
/// This move to trash action has the following behavior:
/// - Allows moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the review out-of-sync changes
class MoveToTrashActionButton extends ConsumerWidget {
final ActionSource source;
final void Function(ActionResult result) onResult;
const MoveToTrashActionButton({super.key, required this.source, required this.onResult});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final selectedCount = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final assetViewerNotifier = ref.read(assetViewerProvider.notifier);
assetViewerNotifier.setControls(false);
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('asset_out_of_sync_trash_confirmation_title'.tr()),
content: Text('asset_out_of_sync_trash_confirmation_text'.t(args: {'count': '$selectedCount'})),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: Text('control_bottom_app_bar_trash_from_immich'.tr()),
),
],
);
},
);
if (confirmed != true) {
assetViewerNotifier.setControls(true);
return;
}
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, keep: false);
onResult.call(result);
multiSelectNotifier.reset();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.delete_forever_outlined;
return (source == ActionSource.viewer)
? BaseActionButton(
maxWidth: 100.0,
iconData: iconData,
label: 'delete'.tr(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: Icon(iconData, color: Colors.red[400]),
label: Text(
'control_bottom_app_bar_trash_from_immich'.tr(),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -2,22 +2,17 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@@ -44,50 +39,29 @@ class ViewerBottomBar extends ConsumerWidget {
final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
final originalTheme = context.themeData;
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote && !isSyncTrashTimeline)
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (isSyncTrashTimeline) ...[
KeepOnDeviceActionButton(
source: ActionSource.viewer,
onResult: (result) {
showKeepResultToast(context, result);
_updateView(result, ref);
},
),
MoveToTrashActionButton(
source: ActionSource.viewer,
onResult: (result) {
showTrashResultToast(context, result);
_updateView(result, ref);
},
),
] else ...[
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
];
@@ -138,15 +112,4 @@ class ViewerBottomBar extends ConsumerWidget {
),
);
}
void _updateView(ActionResult result, WidgetRef ref) {
Future.delayed(Durations.extralong4, () {
if (result.success) {
EventStream.shared.emit(const TimelineReloadEvent());
}
if (ref.context.mounted) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
});
}
}
@@ -9,7 +9,6 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -36,7 +35,6 @@ class ViewerKebabMenu extends ConsumerWidget {
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final isWaitingForTrashApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actionContext = ActionButtonContext(
asset: asset,
@@ -50,7 +48,6 @@ class ViewerKebabMenu extends ConsumerWidget {
source: ActionSource.viewer,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
isWaitingForTrashApproval: isWaitingForTrashApproval,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@@ -15,8 +14,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -50,10 +47,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData;
final isWaitingForSyncApproval =
ref.read(timelineServiceProvider).origin == TimelineOrigin.syncTrash ||
ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
@@ -70,9 +63,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
if (asset.hasRemote && isOwner && !asset.isFavorite && !isWaitingForSyncApproval)
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
@@ -1,38 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
class TrashSyncBottomBar extends ConsumerWidget {
const TrashSyncBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
KeepOnDeviceActionButton(
source: ActionSource.timeline,
onResult: (result) => showKeepResultToast(context, result),
),
MoveToTrashActionButton(
source: ActionSource.timeline,
onResult: (result) => showTrashResultToast(context, result),
),
],
),
),
),
),
);
}
}
@@ -2,8 +2,3 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());
final appSettingStreamProvider = StreamProvider.family.autoDispose<bool, AppSettingsEnum<bool>>((ref, setting) {
final service = ref.watch(appSettingsServiceProvider);
return service.watchSetting(setting);
});
@@ -550,21 +550,6 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> resolveRemoteTrash(ActionSource source, {required bool keep}) async {
final selectedLocalIds = _getAssets(source).map((a) => a.localId).nonNulls.toSet();
_logger.info('resolveRemoteTrash, selectedLocalIds: $selectedLocalIds, keep: $keep');
if (selectedLocalIds.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'Failed to select asset(s)');
}
try {
final result = await _service.resolveRemoteTrash(selectedLocalIds, keep: keep);
return ActionResult(count: result.displayCount, success: result.success);
} catch (error, stack) {
_logger.severe('Failed to ${keep ? 'keep' : 'trash'} assets', error, stack);
return ActionResult(count: selectedLocalIds.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {
@@ -11,7 +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/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -19,7 +20,10 @@ final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService(
syncApiRepository: ref.watch(syncApiRepositoryProvider),
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider),
@@ -35,7 +39,8 @@ final localSyncServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
),
);
@@ -1,47 +1,12 @@
import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>((ref) {
final repo = DriftTrashSyncRepository(
ref.watch(driftProvider),
ref.watch(localAssetRepository),
ref.watch(assetMediaRepositoryProvider),
);
ref.onDispose(repo.dispose);
return repo;
});
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
final repo = ref.watch(trashedLocalAssetRepository);
final total$ = repo.watchCount();
final hashed$ = repo.watchHashedCount();
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
});
final outOfSyncAssetsCountProvider = StreamProvider<int>((ref) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final repo = ref.watch(trashSyncRepositoryProvider);
return enabledReviewMode.when(
data: (enabled) => enabled ? repo.watchPendingReviewCount() : Stream<int>.value(0),
loading: () => Stream<int>.value(0),
error: (_, __) => Stream<int>.value(0),
);
});
final isWaitingForTrashApprovalProvider = StreamProvider.family<bool, String?>((ref, checksum) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final repo = ref.watch(trashSyncRepositoryProvider);
return enabledReviewMode.when(
data: (enabled) => enabled && checksum != null ? repo.watchIsAssetPendingByChecksum(checksum) : Stream.value(false),
loading: () => Stream.value(false),
error: (_, __) => Stream.value(false),
);
});
@@ -8,24 +8,19 @@ 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/platform_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:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
const AssetMediaRepository(this._assetApiRepository);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -50,54 +45,6 @@ class AssetMediaRepository {
return PhotoManager.editor.deleteWithIds(ids);
}
Future<bool> requestManageMediaPermission() async {
try {
return await _nativeSyncApi.requestManageMediaPermission();
} catch (e, s) {
_log.warning('Error requesting manage media permission', e, s);
return false;
}
}
Future<bool> hasManageMediaPermission() async {
try {
return await _nativeSyncApi.hasManageMediaPermission();
} catch (e, s) {
_log.warning('Error requesting manage media permission state', e, s);
return false;
}
}
Future<bool> manageMediaPermission() async {
try {
return await _nativeSyncApi.manageMediaPermission();
} catch (e, s) {
_log.warning('Error requesting manage media permission settings', e, s);
return false;
}
}
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 {
final entity = await AssetEntity.fromId(id);
return entity;
@@ -0,0 +1,51 @@
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;
}
}
-2
View File
@@ -63,7 +63,6 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash_sync_review.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
@@ -164,7 +163,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashSyncReviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),
-16
View File
@@ -1158,22 +1158,6 @@ class DriftTrashRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftTrashSyncReviewPage]
class DriftTrashSyncReviewRoute extends PageRouteInfo<void> {
const DriftTrashSyncReviewRoute({List<PageRouteInfo>? children})
: super(DriftTrashSyncReviewRoute.name, initialChildren: children);
static const String name = 'DriftTrashSyncReviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftTrashSyncReviewPage();
},
);
}
/// generated route for
/// [DriftUploadDetailPage]
class DriftUploadDetailRoute extends PageRouteInfo<void> {
+7 -14
View File
@@ -8,15 +8,14 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/entities/store.entity.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/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
@@ -35,7 +34,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashSyncRepositoryProvider),
ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
@@ -48,7 +47,7 @@ class ActionService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
@@ -59,7 +58,7 @@ class ActionService {
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashSyncRepository,
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
@@ -299,16 +298,10 @@ class ActionService {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashSyncRepository.recordUserManualTrash(deletedIds);
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
/// Apply a user review decision. The HIGH atomicity bug from the
/// original PR cannot recur — `DriftTrashSyncRepository` owns the single
/// transactional surface.
Future<RemoteTrashResolveResult> resolveRemoteTrash(Iterable<String> localAssetIds, {required bool keep}) {
return _trashSyncRepository.applyReviewDecision(localAssetIds, keep: keep);
}
}
@@ -4,7 +4,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
reviewOutOfSyncChangesAndroid<bool>(StoreKey.reviewOutOfSyncChangesAndroid, null, false),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
@@ -24,11 +23,4 @@ class AppSettingsService {
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
Stream<T> watchSetting<T>(AppSettingsEnum<T> setting) async* {
yield getSetting<T>(setting);
await for (final dynamic value in Store.watch(setting.storeKey)) {
yield (value as T?) ?? setting.defaultValue;
}
}
}
@@ -0,0 +1,66 @@
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;
}
}
}
+1 -3
View File
@@ -12,13 +12,11 @@ class ImmichTheme {
ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale}) {
final isDark = colorScheme.brightness == Brightness.dark;
final warningColor = isDark ? const Color(0xFFF3BC6A) : const Color(0xFFC47A00);
final onWarningColor = isDark ? Colors.black : Colors.white;
return ThemeData(
useMaterial3: true,
brightness: colorScheme.brightness,
colorScheme: colorScheme.copyWith(tertiary: warningColor, onTertiary: onWarningColor),
colorScheme: colorScheme,
primaryColor: colorScheme.primary,
hintColor: colorScheme.onSurfaceSecondary,
focusColor: colorScheme.primary,
+7 -17
View File
@@ -47,7 +47,6 @@ class ActionButtonContext {
final bool isCasting;
final TimelineOrigin timelineOrigin;
final int selectedCount;
final bool isWaitingForTrashApproval;
const ActionButtonContext({
required this.asset,
@@ -62,7 +61,6 @@ class ActionButtonContext {
this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main,
this.selectedCount = 1,
this.isWaitingForTrashApproval = false,
});
}
@@ -104,8 +102,7 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
!context.isArchived &&
!context.isWaitingForTrashApproval,
!context.isArchived,
ActionButtonType.unarchive =>
context.isOwner && //
!context.isInLockedView && //
@@ -120,37 +117,31 @@ enum ActionButtonType {
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
context.timelineOrigin == TimelineOrigin.trash,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView) &&
!context.isWaitingForTrashApproval,
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
context.asset.hasRemote,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
context.asset.hasRemote,
ActionButtonType.removeFromLockFolder =>
context.isOwner && //
context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.hasLocal &&
!context.isWaitingForTrashApproval,
context.asset.hasLocal,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
@@ -188,7 +179,6 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.timelineOrigin != TimelineOrigin.syncTrash &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.slideshow => true,
@@ -6,13 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -70,24 +68,19 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing, Color? btnColor}) {
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing}) {
return ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30, right: 30),
minLeadingWidth: 40,
leading: SizedBox(
child: Icon(icon, color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20),
),
leading: SizedBox(child: Icon(icon, color: theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20)),
title: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250),
),
style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)),
).tr(),
onTap: onTap,
trailing: trailing,
iconColor: btnColor,
);
}
@@ -103,25 +96,6 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
Widget buildOutOfSyncButton() {
return Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
if (outOfSyncCount == 0) {
return const SizedBox.shrink();
}
final btnColor = theme.colorScheme.tertiary;
return buildActionButton(
Icons.warning_amber_rounded,
'review_out_of_sync_changes'.t(),
() => context.pushRoute(const DriftTrashSyncReviewRoute()),
trailing: Text('($outOfSyncCount)', style: theme.textTheme.labelLarge?.copyWith(color: btnColor)),
btnColor: btnColor,
);
},
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@@ -295,7 +269,6 @@ class ImmichAppBarDialog extends HookConsumerWidget {
],
),
),
buildOutOfSyncButton(),
if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),
@@ -12,7 +12,6 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -112,7 +111,6 @@ class _ProfileIndicator extends ConsumerWidget {
// TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
@@ -149,7 +147,7 @@ class _ProfileIndicator extends ConsumerWidget {
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: versionWarningPresent || outOfSyncCount > 0,
isLabelVisible: versionWarningPresent,
offset: const Offset(-2, -12),
child: user == null
? const Icon(Icons.face_outlined, size: widgetSize)
@@ -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/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.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/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
}
getManageMediaPermission() async {
final hasPermission = await ref.read(assetMediaRepositoryProvider).hasManageMediaPermission();
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
),
TextButton(
onPressed: () {
ref.read(assetMediaRepositoryProvider).requestManageMediaPermission();
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
@@ -7,20 +7,17 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/app_settings.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/readonly_mode.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/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
import 'package:logging/logging.dart';
@@ -31,7 +28,9 @@ class AdvancedSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged(
@@ -57,6 +56,11 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref
.read(localFilesManagerRepositoryProvider)
.hasManageMediaPermission();
}
}();
return null;
}, []);
@@ -68,11 +72,36 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
// Android 12+: full selector (Off / Auto sync / Review) + MANAGE_MEDIA tile.
// iOS: reduced selector (Off / Review) — no MANAGE_MEDIA on this
// platform; auto-sync is dropped because PhotoKit prompts on
// every batch, which would defeat the "set and forget" intent.
if (isManageMediaSupported.value || Platform.isIOS) const _TrashSyncModeSelector(),
if (isManageMediaSupported.value)
Column(
children: [
SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
}
},
),
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result;
},
),
],
),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
valueNotifier: levelId,
@@ -149,135 +178,3 @@ class AdvancedSettings extends HookConsumerWidget {
return SettingsSubPageScaffold(settings: advancedSettings);
}
}
enum _TrashSyncMode { none, auto, review }
final _manageMediaPermissionProvider = FutureProvider<bool>((ref) async {
return ref.watch(assetMediaRepositoryProvider).hasManageMediaPermission();
});
class _TrashSyncModeSelector extends HookConsumerWidget {
const _TrashSyncModeSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoSyncChanges = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final reviewOutOfSyncChanges = useAppSettingsState(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
final manageMediaAndroidPermission = ref.watch(_manageMediaPermissionProvider);
final manageMediaAndroidPermissionValue = manageMediaAndroidPermission.valueOrNull;
final selectedTrashSyncMode = autoSyncChanges.value
? _TrashSyncMode.auto
: reviewOutOfSyncChanges.value
? _TrashSyncMode.review
: _TrashSyncMode.none;
Future<void> attemptToEnableSetting(AppSettingsEnum key) async {
if (Platform.isIOS) {
// No MANAGE_MEDIA on iOS; review is the only mode the user can pick.
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
reviewOutOfSyncChanges.value = true;
autoSyncChanges.value = false;
}
ref.invalidate(appSettingsServiceProvider);
return;
}
final result = await ref.read(assetMediaRepositoryProvider).requestManageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
if (key == AppSettingsEnum.manageLocalMediaAndroid) {
autoSyncChanges.value = result;
if (result) {
reviewOutOfSyncChanges.value = false;
}
}
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
reviewOutOfSyncChanges.value = result;
if (result) {
autoSyncChanges.value = false;
}
}
ref.invalidate(appSettingsServiceProvider);
}
Future<void> handleTrashSyncModeChange(_TrashSyncMode? mode) async {
if (mode == null) {
return;
}
switch (mode) {
case _TrashSyncMode.none:
if (!autoSyncChanges.value && !reviewOutOfSyncChanges.value) {
break;
}
autoSyncChanges.value = false;
reviewOutOfSyncChanges.value = false;
ref.invalidate(appSettingsServiceProvider);
break;
case _TrashSyncMode.auto:
if (autoSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.manageLocalMediaAndroid);
break;
case _TrashSyncMode.review:
if (reviewOutOfSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
break;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "advanced_settings_sync_remote_deletions_selector_title".tr()),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'off'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_off_subtitle'.tr(),
value: _TrashSyncMode.none,
),
// Auto-sync requires MANAGE_MEDIA to run silently. iOS has no
// equivalent permission and every batch would trigger a PhotoKit
// prompt — so the auto mode is intentionally hidden there.
if (!Platform.isIOS)
SettingsRadioGroup(
title: 'advanced_settings_sync_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_subtitle'.tr(),
value: _TrashSyncMode.auto,
),
SettingsRadioGroup(
title: 'advanced_settings_review_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_review_remote_deletions_subtitle'.tr(),
value: _TrashSyncMode.review,
),
],
groupBy: selectedTrashSyncMode,
onRadioChanged: (mode) => handleTrashSyncModeChange(mode),
),
// MANAGE_MEDIA permission tile is Android-only; iOS has no equivalent.
if (!Platform.isIOS)
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermissionValue == null
? null
: manageMediaAndroidPermissionValue == true
? "allowed".tr()
: "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor:
manageMediaAndroidPermissionValue == false && (autoSyncChanges.value || reviewOutOfSyncChanges.value)
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
await ref.read(assetMediaRepositoryProvider).manageMediaPermission();
ref.invalidate(_manageMediaPermissionProvider);
},
),
],
);
}
}
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -355,10 +354,8 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
// To be removed once the experimental feature is stable
if ((kDebugMode || kProfileMode) &&
CurrentPlatform.isAndroid &&
(appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid) ||
appSettingsService.getSetting<bool>(AppSettingsEnum.reviewOutOfSyncChangesAndroid))) ...[
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
SettingGroupTitle(title: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {
@@ -1,13 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class SettingsRadioGroup<T> {
final String title;
final String? subtitle;
final T value;
const SettingsRadioGroup({required this.title, this.subtitle, required this.value});
const SettingsRadioGroup({required this.title, required this.value});
}
class SettingsRadioListTile<T> extends StatelessWidget {
@@ -30,12 +28,6 @@ class SettingsRadioListTile<T> extends StatelessWidget {
dense: true,
activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: g.subtitle != null
? Text(
g.subtitle!,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
)
: null,
value: g.value,
controlAffinity: ListTileControlAffinity.trailing,
),
+8 -12
View File
@@ -11,7 +11,14 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile',
),
)
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset {
final String id;
@@ -135,17 +142,6 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets();
bool hasManageMediaPermission();
@async
bool requestManageMediaPermission();
@async
bool manageMediaPermission();
@async
bool restoreFromTrashById(String mediaId, int type);
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
}
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
@@ -11,32 +10,32 @@ import 'package:immich_mobile/entities/store.entity.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_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/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
void main() {
late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late MockDriftTrashSyncRepository mockDriftTrashSyncRepository;
late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository;
late MockNativeSyncApi mockNativeSyncApi;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<LocalAsset>[]);
registerFallbackValue(<LocalAlbum>[]);
registerFallbackValue(<String>[]);
registerFallbackValue(<String, List<String>>{});
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
@@ -49,12 +48,11 @@ void main() {
});
setUp(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockDriftTrashSyncRepository = MockDriftTrashSyncRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository();
mockNativeSyncApi = MockNativeSyncApi();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
@@ -62,57 +60,70 @@ void main() {
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
when(() => mockNativeSyncApi.checkpointSync()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
when(() => mockDriftTrashSyncRepository.cleanup()).thenAnswer((_) async => 0);
when(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
trashSyncRepository: mockDriftTrashSyncRepository,
localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository,
nativeSyncApi: mockNativeSyncApi,
);
await Store.clear();
await Store.put(StoreKey.manageLocalMediaAndroid, false);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
});
// After the refactor, LocalSyncService is just the OS-trash mirror
// updater plus a delegating cleanup hook. The restore branch that
// used to live here is now owned by DriftTrashSyncRepository (tested in
// trash_sync_service_test.dart / trash_sync_repository_test.dart).
group('LocalSyncService - OS trash mirror', () {
test('updates mirror on Android regardless of store flags', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
group('LocalSyncService - syncTrashedAssets gating', () {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
});
test('invokes catch-up restore on Android', () async {
// Regression: my refactor initially dropped this catch-up. The
// original PR ran restore detection in `processTrashedAssets`
// every sync. We preserve that periodic check via
// DriftTrashSyncRepository.syncRestoresForRevivedAssets.
await sut.sync();
verify(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).called(1);
});
test('skips mirror and catch-up on non-Android platforms', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
test('skips syncTrashedAssets when store flag disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
verifyNever(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets());
});
test('processTrashedAssets writes the OS mirror and no longer calls restore', () async {
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
test('skips syncTrashedAssets on non-Android platforms', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
});
});
group('LocalSyncService - syncTrashedAssets behavior', () {
test('processes trashed snapshot, restores assets, and trashes local files', () async {
final platformAsset = PlatformAsset(
id: 'remote-id',
name: 'remote.jpg',
@@ -120,9 +131,29 @@ void main() {
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final assetsToRestore = [LocalAssetStub.image1];
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
final restoredIds = ['image1'];
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requested, orderedEquals(assetsToRestore));
return restoredIds;
});
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [localAssetToTrash],
},
);
final assetEntity = MockAssetEntity();
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
await sut.processTrashedAssets({
'album-a': [platformAsset],
});
@@ -135,47 +166,41 @@ void main() {
expect(trashedEntry.albumId, 'album-a');
expect(trashedEntry.asset.id, platformAsset.id);
expect(trashedEntry.asset.name, platformAsset.name);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
expect(moveArgs, ['content://local-trash']);
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [localAssetToTrash]);
});
test('processTrashedAssets handles empty snapshot without errors', () async {
test('does not attempt restore when repository has no assets to restore', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
await sut.processTrashedAssets({});
final trashedSnapshot =
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
as Iterable<TrashedAsset>;
expect(trashedSnapshot, isEmpty);
});
});
group('LocalSyncService - cleanup delegation', () {
test('cleans trash state after Android full sync', () async {
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => true);
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
when(() => mockLocalAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id})).thenAnswer((_) async => []);
await sut.sync();
verify(() => mockDriftTrashSyncRepository.cleanup()).called(1);
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
});
test('cleans trash state after Android delta sync with changes', () async {
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
(_) async => SyncDelta(hasChanges: true, updates: const [], deletes: const [], assetAlbums: const {}),
);
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
when(() => mockLocalAlbumRepository.updateAll(any())).thenAnswer((_) async {});
when(
() => mockLocalAlbumRepository.processDelta(
updates: any(named: 'updates'),
deletes: any(named: 'deletes'),
assetAlbums: any(named: 'assetAlbums'),
),
).thenAnswer((_) async {});
when(() => mockLocalAlbumRepository.getAll()).thenAnswer((_) async => []);
test('does not move local assets when repository finds nothing to trash', () async {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
await sut.sync();
await sut.processTrashedAssets({});
verify(() => mockDriftTrashSyncRepository.cleanup()).called(1);
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
});
});
@@ -190,7 +215,7 @@ void main() {
isFavorite: false,
createdAt: 1700000000,
updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final localAsset = platformAsset.toLocalAsset();
@@ -4,16 +4,20 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/entities/store.entity.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/storage.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_stream.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/utils/semver.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -22,6 +26,8 @@ import '../../api.mocks.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
class _AbortCallbackWrapper {
@@ -44,7 +50,10 @@ void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
late SyncApiRepository mockSyncApiRepo;
late DriftTrashSyncRepository mockDriftTrashSyncRepository;
late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late MockApiService mockApi;
late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo;
@@ -52,6 +61,7 @@ void main() {
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
late Drift db;
late bool hasManageMediaPermission;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -60,7 +70,7 @@ void main() {
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
tearDownAll(() async {
@@ -74,7 +84,10 @@ void main() {
setUp(() async {
mockSyncStreamRepo = MockSyncStreamRepository();
mockSyncApiRepo = MockSyncApiRepository();
mockDriftTrashSyncRepository = MockDriftTrashSyncRepository();
mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService();
@@ -144,15 +157,24 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
trashSyncRepository: mockDriftTrashSyncRepository,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
when(() => mockDriftTrashSyncRepository.recordRemoteTrash(any())).thenAnswer((_) async {});
when(() => mockDriftTrashSyncRepository.recordRemoteRestore(any())).thenAnswer((_) async {});
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
hasManageMediaPermission = false;
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
});
Future<void> simulateEvents(List<SyncEvent> events) async {
@@ -217,7 +239,10 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
trashSyncRepository: mockDriftTrashSyncRepository,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@@ -255,7 +280,10 @@ void main() {
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo,
trashSyncRepository: mockDriftTrashSyncRepository,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@@ -368,73 +396,128 @@ void main() {
});
});
// After the refactor, SyncStreamService's only job for trash events
// is to extract (id, deletedAt, checksum) tuples and delegate to
// DriftTrashSyncRepository. The detailed behaviour (auto-trash, review-mode
// queueing, OS permission gating, restore branching) lives in
// DriftTrashSyncRepository and is tested at that layer.
group("SyncStreamService - delegates trash events to DriftTrashSyncRepository", () {
test("assetV1 with deletedAt routes trash intents through recordRemoteTrash", () async {
final trashedAt = DateTime(2025, 5, 1);
group("SyncStreamService - remote trash & restore", () {
setUp(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
hasManageMediaPermission = true;
});
tearDown(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
hasManageMediaPermission = false;
});
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
final mergedAsset = LocalAssetStub.image2.copyWith(
id: 'merged-local',
checksum: 'checksum-merged',
remoteId: 'remote-merged',
);
final assetsByAlbum = {
'album-a': [localAsset],
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
return assetsByAlbum;
});
final localEntity = MockAssetEntity();
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
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 = [
SyncStreamStub.assetTrashed(
id: 'remote-1',
checksum: 'checksum-1',
ack: 'asset-trashed-1',
trashedAt: trashedAt,
checksum: localAsset.checksum!,
ack: 'asset-remote-local-1',
trashedAt: DateTime(2025, 5, 1),
),
SyncStreamStub.assetTrashed(
id: 'remote-2',
checksum: mergedAsset.checksum!,
ack: 'asset-remote-merged-2',
trashedAt: DateTime(2025, 5, 2),
),
SyncStreamStub.assetTrashed(
id: 'remote-3',
checksum: 'checksum-remote-only',
ack: 'asset-remote-only-3',
trashedAt: DateTime(2025, 5, 3),
),
];
await simulateEvents(events);
final captured =
verify(() => mockDriftTrashSyncRepository.recordRemoteTrash(captureAny())).captured.single
as Map<String, DateTime>;
expect(captured, {'remote-1': trashedAt});
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
});
test("assetV1 with null deletedAt routes alive checksums through recordRemoteRestore", () async {
final events = [SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-1', ack: 'asset-restored-1')];
test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-only',
checksum: 'checksum-only',
ack: 'asset-remote-only-9',
trashedAt: DateTime(2025, 6, 1),
),
];
await simulateEvents(events);
final captured =
verify(() => mockDriftTrashSyncRepository.recordRemoteRestore(captureAny())).captured.single
as Iterable<String>;
expect(captured.toList(), ['checksum-1']);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
test("assetDeleteV1 events route through recordRemoteTrash (permanent delete)", () async {
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
return {};
});
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verify(() => mockDriftTrashSyncRepository.recordRemoteTrash(any())).called(1);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
test("mixed batch routes trash and restore in one call each", () async {
final trashedAt = DateTime(2025, 5, 1);
test("restores trashed local assets once the matching remote assets leave the trash", () async {
final trashedAssets = [
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
];
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
final restoredIds = ['trashed-1'];
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requestedAssets, orderedEquals(trashedAssets));
return restoredIds;
});
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-trashed',
checksum: 'checksum-trashed',
ack: 'asset-trashed',
trashedAt: trashedAt,
),
SyncStreamStub.assetModified(id: 'remote-alive', checksum: 'checksum-alive', ack: 'asset-alive'),
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'),
];
await simulateEvents(events);
final trashCaptured =
verify(() => mockDriftTrashSyncRepository.recordRemoteTrash(captureAny())).captured.single
as Map<String, DateTime>;
expect(trashCaptured, {'remote-trashed': trashedAt});
final restoreCaptured =
verify(() => mockDriftTrashSyncRepository.recordRemoteRestore(captureAny())).captured.single
as Iterable<String>;
expect(restoreCaptured.toList(), ['checksum-alive']);
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
});
});
-4
View File
@@ -30,7 +30,6 @@ import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -88,8 +87,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v25.DatabaseAtV25(db);
case 26:
return v26.DatabaseAtV26(db);
case 27:
return v27.DatabaseAtV27(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -122,6 +119,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
24,
25,
26,
27,
];
}
File diff suppressed because it is too large Load Diff
@@ -1,165 +0,0 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
late Drift db;
late DriftTimelineRepository repository;
setUpAll(() async {
await initializeDateFormatting('en');
});
setUp(() {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
repository = DriftTimelineRepository(db);
});
tearDown(() async {
await db.close();
});
Future<void> insertLocalAsset({required String id, required String checksum, required DateTime createdAt}) {
return db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: id,
checksum: Value(checksum),
name: '$id.jpg',
type: AssetType.image,
createdAt: Value(createdAt),
updatedAt: Value(createdAt),
),
);
}
Future<void> insertLocalAlbum({required String id, BackupSelection backupSelection = BackupSelection.selected}) {
return db
.into(db.localAlbumEntity)
.insert(LocalAlbumEntityCompanion.insert(id: id, name: id, backupSelection: backupSelection));
}
Future<void> insertLocalAlbumAsset({required String albumId, required String assetId}) {
return db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
/// Seeds a pending-review trash_sync_entity row for [localAssetId].
///
/// The new single-table design keys decisions by local_asset_id (not
/// checksum), so each pending review must reference an existing local
/// asset. The checksum is stored alongside for cross-matching with
/// remote_asset rows during cleanup.
Future<void> insertTrashSync({required String localAssetId, String? checksum}) {
final now = DateTime(2025, 1, 10, 12);
return db
.into(db.trashSyncEntity)
.insert(
TrashSyncEntityCompanion.insert(
localAssetId: localAssetId,
checksum: Value(checksum),
decision: TrashStateDecision.pendingReview,
triggerSource: TrashTriggerSource.remoteSync,
remoteDeletedAt: Value(now),
name: '$localAssetId.jpg',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
),
);
}
Future<void> insertTrashedLocalAsset(String checksum, {String? id}) {
final now = DateTime(2025, 1, 10, 12);
return db
.into(db.trashedLocalAssetEntity)
.insert(
TrashedLocalAssetEntityCompanion.insert(
id: id ?? 'trashed-$checksum',
albumId: 'album-$checksum',
checksum: Value(checksum),
name: 'trashed-$checksum.jpg',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
source: TrashOrigin.localSync,
),
);
}
group('toTrashSyncReview', () {
test('returns local assets with a pending-review trash state in backup-selected albums', () async {
await insertLocalAlbum(id: 'selected-album');
await insertLocalAlbum(id: 'unselected-album', backupSelection: BackupSelection.none);
// Two local copies of the same remote asset. The new schema records
// a pending state per local asset id, so we seed it for the one we
// expect to be shown for review.
await insertLocalAsset(id: 'a-duplicate', checksum: 'duplicate-checksum', createdAt: DateTime(2025, 1, 1, 12));
await insertLocalAsset(
id: 'z-newer-duplicate',
checksum: 'duplicate-checksum',
createdAt: DateTime(2025, 1, 2, 12),
);
await insertLocalAsset(id: 'single', checksum: 'single-checksum', createdAt: DateTime(2025, 1, 3, 12));
await insertLocalAsset(id: 'unselected', checksum: 'unselected-checksum', createdAt: DateTime(2025, 1, 5, 12));
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'a-duplicate');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'z-newer-duplicate');
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'single');
await insertLocalAlbumAsset(albumId: 'unselected-album', assetId: 'unselected');
// The service-level `recordRemoteTrash` dedupes by local_asset_id and
// only writes a state row for the first-found local copy of a given
// remote — so for the duplicate checksum case we seed exactly one row.
await insertTrashSync(localAssetId: 'a-duplicate', checksum: 'duplicate-checksum');
await insertTrashSync(localAssetId: 'single', checksum: 'single-checksum');
await insertTrashSync(localAssetId: 'unselected', checksum: 'unselected-checksum');
final query = repository.toTrashSyncReview(GroupAssetsBy.day);
final assets = await query.assetSource(0, 10);
final localIds = assets.whereType<LocalAsset>().map((asset) => asset.id).toList();
expect(localIds, ['single', 'a-duplicate']);
// z-newer-duplicate has no state row → not shown.
expect(localIds, isNot(contains('z-newer-duplicate')));
// unselected has a state row but is only in an unselected album.
expect(localIds, isNot(contains('unselected')));
final buckets = await query.bucketSource().first;
expect(buckets.map((bucket) => bucket.assetCount), [1, 1]);
});
test('shows the alive local copy even when a same-checksum sibling exists in local trash', () async {
await insertLocalAlbum(id: 'selected-album');
await insertLocalAsset(id: 'alive-copy', checksum: 'shared-checksum', createdAt: DateTime(2025, 1, 1, 12));
await insertLocalAlbumAsset(albumId: 'selected-album', assetId: 'alive-copy');
await insertTrashSync(localAssetId: 'alive-copy', checksum: 'shared-checksum');
await insertTrashedLocalAsset('shared-checksum', id: 'trashed-copy');
final query = repository.toTrashSyncReview(GroupAssetsBy.day);
final assets = await query.assetSource(0, 10);
final localIds = assets.whereType<LocalAsset>().map((asset) => asset.id).toList();
expect(localIds, ['alive-copy']);
});
});
}
@@ -1,391 +0,0 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.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/trash_sync.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:mocktail/mocktail.dart';
class _MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
void main() {
late Drift db;
late DriftTrashSyncRepository repository;
setUp(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
// The state-machine entry points (recordRemoteTrash, applyReviewDecision,
// etc.) are tested separately at the service-equivalent layer via the
// mocked repositories below. This test focuses on the low-level table
// writes, watch streams, and cleanup — none of which call the injected
// collaborators.
repository = DriftTrashSyncRepository(db, DriftLocalAssetRepository(db), _MockAssetMediaRepository());
await db
.into(db.userEntity)
.insert(UserEntityCompanion.insert(id: 'user-1', name: 'user-1', email: 'user-1@example.com'));
});
tearDown(() async {
await db.close();
});
// ---------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------
TrashSyncCandidate candidate({
required String localAssetId,
String? checksum,
String? albumId,
DateTime? remoteDeletedAt,
TrashTriggerSource triggerSource = TrashTriggerSource.remoteSync,
}) {
final now = DateTime(2025, 1, 1);
return TrashSyncCandidate(
localAssetId: localAssetId,
checksum: checksum,
albumId: albumId,
remoteDeletedAt: remoteDeletedAt ?? now,
triggerSource: triggerSource,
name: '$localAssetId.jpg',
type: AssetType.image,
createdAt: now,
updatedAt: now,
width: 100,
height: 100,
durationMs: 0,
isFavorite: false,
orientation: 0,
playbackStyle: AssetPlaybackStyle.image,
);
}
Future<void> insertStateRow({
required String localAssetId,
String? checksum,
TrashStateDecision decision = TrashStateDecision.pendingReview,
TrashTriggerSource triggerSource = TrashTriggerSource.remoteSync,
String? albumId,
DateTime? remoteDeletedAt,
}) async {
final now = DateTime(2025, 1, 1);
await db
.into(db.trashSyncEntity)
.insert(
TrashSyncEntityCompanion.insert(
localAssetId: localAssetId,
checksum: Value(checksum),
decision: decision,
triggerSource: triggerSource,
albumId: Value(albumId),
remoteDeletedAt: Value(remoteDeletedAt ?? now),
name: '$localAssetId.jpg',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
width: const Value(100),
height: const Value(100),
durationMs: const Value(0),
isFavorite: const Value(false),
orientation: const Value(0),
playbackStyle: const Value(AssetPlaybackStyle.image),
),
);
}
Future<void> insertRemoteAsset({required String checksum, DateTime? deletedAt}) async {
final now = DateTime(2025, 1, 1);
await db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: 'remote-$checksum',
checksum: checksum,
name: 'remote-$checksum.jpg',
ownerId: 'user-1',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
visibility: AssetVisibility.timeline,
deletedAt: Value(deletedAt),
),
);
}
Future<void> insertLocalAlbum({
required String id,
BackupSelection backupSelection = BackupSelection.selected,
}) async {
await db
.into(db.localAlbumEntity)
.insert(LocalAlbumEntityCompanion.insert(id: id, name: id, backupSelection: backupSelection));
}
Future<void> insertLocalAlbumAsset({required String albumId, required String assetId}) async {
await db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
Future<void> insertLocalAsset({required String id, String? checksum}) async {
final now = DateTime(2025, 1, 1);
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: id,
checksum: Value(checksum),
name: '$id.jpg',
type: AssetType.image,
createdAt: Value(now),
updatedAt: Value(now),
),
);
}
// ---------------------------------------------------------------------
// upsertCandidates
// ---------------------------------------------------------------------
group('upsertCandidates', () {
test('inserts new pending rows', () async {
await repository.upsertCandidates([
candidate(localAssetId: 'local-1', checksum: 'sum-1'),
candidate(localAssetId: 'local-2', checksum: 'sum-2'),
]);
final rows = await db.select(db.trashSyncEntity).get();
expect(rows.length, 2);
expect(rows.every((r) => r.decision == TrashStateDecision.pendingReview), isTrue);
});
test('does not overwrite existing decisions (insertOrIgnore)', () async {
// Pre-existing decided row.
await insertStateRow(localAssetId: 'local-1', checksum: 'sum-1', decision: TrashStateDecision.kept);
// Repeat remote-delete event for the same asset.
await repository.upsertCandidates([candidate(localAssetId: 'local-1', checksum: 'sum-1')]);
final row = (await db.select(db.trashSyncEntity).get()).single;
// Decision preserved — suppression is the whole point of `kept`.
expect(row.decision, TrashStateDecision.kept);
});
test('no-op on empty input', () async {
await repository.upsertCandidates(const []);
final rows = await db.select(db.trashSyncEntity).get();
expect(rows, isEmpty);
});
});
// ---------------------------------------------------------------------
// markDecision
// ---------------------------------------------------------------------
group('markDecision', () {
test('transitions pending rows to appTrashed', () async {
await insertStateRow(localAssetId: 'local-1', checksum: 'sum-1');
await insertStateRow(localAssetId: 'local-2', checksum: 'sum-2');
await repository.markDecision(['local-1'], TrashStateDecision.appTrashed);
final rows = await db.select(db.trashSyncEntity).get();
final byId = {for (final r in rows) r.localAssetId: r};
expect(byId['local-1']!.decision, TrashStateDecision.appTrashed);
expect(byId['local-2']!.decision, TrashStateDecision.pendingReview);
});
test('transitions pending rows to kept', () async {
await insertStateRow(localAssetId: 'local-1', checksum: 'sum-1');
await repository.markDecision(['local-1'], TrashStateDecision.kept);
final row = (await db.select(db.trashSyncEntity).get()).single;
expect(row.decision, TrashStateDecision.kept);
});
});
// ---------------------------------------------------------------------
// Watch streams
// ---------------------------------------------------------------------
group('watch streams', () {
test('watchPendingReviewCount counts only pending rows in backup-selected albums', () async {
await insertLocalAlbum(id: 'selected', backupSelection: BackupSelection.selected);
await insertLocalAlbum(id: 'unselected', backupSelection: BackupSelection.none);
// Pending, in selected album → counted.
await insertLocalAsset(id: 'a', checksum: 'sum-a');
await insertLocalAlbumAsset(albumId: 'selected', assetId: 'a');
await insertStateRow(localAssetId: 'a', checksum: 'sum-a');
// Pending, but only in unselected album → not counted.
await insertLocalAsset(id: 'b', checksum: 'sum-b');
await insertLocalAlbumAsset(albumId: 'unselected', assetId: 'b');
await insertStateRow(localAssetId: 'b', checksum: 'sum-b');
// Kept tombstone in selected album → not counted.
await insertLocalAsset(id: 'c', checksum: 'sum-c');
await insertLocalAlbumAsset(albumId: 'selected', assetId: 'c');
await insertStateRow(localAssetId: 'c', checksum: 'sum-c', decision: TrashStateDecision.kept);
// appTrashed → not counted (already resolved).
await insertLocalAsset(id: 'd', checksum: 'sum-d');
await insertLocalAlbumAsset(albumId: 'selected', assetId: 'd');
await insertStateRow(localAssetId: 'd', checksum: 'sum-d', decision: TrashStateDecision.appTrashed);
// Pending row with no matching local_asset → not counted.
await insertStateRow(localAssetId: 'e', checksum: 'sum-e');
await expectLater(repository.watchPendingReviewCount(), emits(1));
});
test('watchIsAssetPendingById reflects backup-selection and decision state', () async {
await insertLocalAlbum(id: 'selected');
await insertLocalAlbum(id: 'unselected', backupSelection: BackupSelection.none);
await insertLocalAsset(id: 'pending-selected', checksum: 'sum-1');
await insertLocalAlbumAsset(albumId: 'selected', assetId: 'pending-selected');
await insertStateRow(localAssetId: 'pending-selected', checksum: 'sum-1');
await insertLocalAsset(id: 'pending-unselected', checksum: 'sum-2');
await insertLocalAlbumAsset(albumId: 'unselected', assetId: 'pending-unselected');
await insertStateRow(localAssetId: 'pending-unselected', checksum: 'sum-2');
await insertLocalAsset(id: 'kept', checksum: 'sum-3');
await insertLocalAlbumAsset(albumId: 'selected', assetId: 'kept');
await insertStateRow(localAssetId: 'kept', checksum: 'sum-3', decision: TrashStateDecision.kept);
await expectLater(repository.watchIsAssetPendingById('pending-selected'), emits(true));
await expectLater(repository.watchIsAssetPendingById('pending-unselected'), emits(false));
await expectLater(repository.watchIsAssetPendingById('kept'), emits(false));
await expectLater(repository.watchIsAssetPendingById('nonexistent'), emits(false));
});
test('watchIsAssetPendingByChecksum works on the indexed checksum column', () async {
await insertLocalAlbum(id: 'selected');
await insertLocalAsset(id: 'a', checksum: 'sum-a');
await insertLocalAlbumAsset(albumId: 'selected', assetId: 'a');
await insertStateRow(localAssetId: 'a', checksum: 'sum-a');
await expectLater(repository.watchIsAssetPendingByChecksum('sum-a'), emits(true));
await expectLater(repository.watchIsAssetPendingByChecksum('sum-nope'), emits(false));
});
});
// ---------------------------------------------------------------------
// deleteForRestoredRemotes
// ---------------------------------------------------------------------
group('deleteForRestoredRemotes', () {
test('returns affected appTrashed rows and removes only remoteSync triggers', () async {
await insertStateRow(
localAssetId: 'a',
checksum: 'sum-a',
decision: TrashStateDecision.appTrashed,
triggerSource: TrashTriggerSource.remoteSync,
);
await insertStateRow(
localAssetId: 'b',
checksum: 'sum-b',
decision: TrashStateDecision.appTrashed,
triggerSource: TrashTriggerSource.localUser, // user-manual: NOT touched
);
await insertStateRow(
localAssetId: 'c',
checksum: 'sum-a',
decision: TrashStateDecision.kept,
triggerSource: TrashTriggerSource.remoteSync,
);
final affected = await repository.deleteForRestoredRemotes(['sum-a', 'sum-b']);
// 'a' and 'c' were removed (both remoteSync, both matched sum-a).
// 'b' was not removed (localUser trigger).
expect(affected.map((r) => r.localAssetId).toSet(), {'a', 'c'});
final remaining = await db.select(db.trashSyncEntity).get();
expect(remaining.map((r) => r.localAssetId).toSet(), {'b'});
});
test('empty input is a no-op', () async {
await insertStateRow(localAssetId: 'a', checksum: 'sum-a');
final affected = await repository.deleteForRestoredRemotes(const []);
expect(affected, isEmpty);
final remaining = await db.select(db.trashSyncEntity).get();
expect(remaining.length, 1);
});
});
// ---------------------------------------------------------------------
// cleanup — single transactional sweep, two rules
// ---------------------------------------------------------------------
group('cleanup', () {
test('rule 1: deletes rows whose remote is alive again', () async {
await insertRemoteAsset(checksum: 'sum-alive', deletedAt: null);
await insertRemoteAsset(checksum: 'sum-deleted', deletedAt: DateTime(2025, 1, 1));
// Insert local_asset rows so rule 2 (orphaned-local cleanup) doesn't
// fire — we want to verify rule 1 in isolation.
await insertLocalAsset(id: 'a', checksum: 'sum-alive');
await insertLocalAsset(id: 'b', checksum: 'sum-deleted');
await insertStateRow(localAssetId: 'a', checksum: 'sum-alive');
await insertStateRow(localAssetId: 'b', checksum: 'sum-deleted');
final deleted = await repository.cleanup();
expect(deleted, 1);
final remaining = await db.select(db.trashSyncEntity).get();
expect(remaining.map((r) => r.localAssetId).toSet(), {'b'});
});
test('rule 2: deletes rows whose local_asset is gone and state != appTrashed', () async {
// local_asset exists for 'a' → not orphan
await insertLocalAsset(id: 'a', checksum: 'sum-a');
await insertStateRow(localAssetId: 'a', checksum: 'sum-a');
// local_asset missing for 'b', pending → deleted
await insertStateRow(localAssetId: 'b', checksum: 'sum-b');
// local_asset missing for 'c', kept → deleted
await insertStateRow(localAssetId: 'c', checksum: 'sum-c', decision: TrashStateDecision.kept);
// local_asset missing for 'd', appTrashed → KEPT (needed for restore)
await insertStateRow(localAssetId: 'd', checksum: 'sum-d', decision: TrashStateDecision.appTrashed);
final deleted = await repository.cleanup();
expect(deleted, 2);
final remaining = await db.select(db.trashSyncEntity).get();
expect(remaining.map((r) => r.localAssetId).toSet(), {'a', 'd'});
});
test('both rules apply in one transaction', () async {
await insertRemoteAsset(checksum: 'sum-alive', deletedAt: null);
// Will hit rule 1: alive remote.
await insertStateRow(localAssetId: 'rule1', checksum: 'sum-alive');
// Will hit rule 2: orphan + not appTrashed.
await insertStateRow(localAssetId: 'rule2', checksum: 'sum-orphan');
// Survives both rules.
await insertLocalAsset(id: 'survivor', checksum: 'sum-keep');
await insertStateRow(localAssetId: 'survivor', checksum: 'sum-keep');
final deleted = await repository.cleanup();
expect(deleted, 2);
final remaining = await db.select(db.trashSyncEntity).get();
expect(remaining.map((r) => r.localAssetId).toSet(), {'survivor'});
});
});
}
@@ -10,7 +10,6 @@ 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_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@@ -37,8 +36,6 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockDriftTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
@@ -1,10 +1,6 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/utils/option.dart';
@@ -23,87 +19,6 @@ void main() {
await ctx.dispose();
});
group('getRemoteTrashCandidatesByAlbum', () {
test('returns local assets from selected backup albums matched by remote id', () async {
final user = await ctx.newUser();
final remoteDeletedAt = DateTime(2025, 6, 1);
final remoteAsset = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: remoteDeletedAt);
final localAsset = await ctx.newLocalAsset(checksum: remoteAsset.checksum);
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final unselectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: localAsset.id);
await ctx.newLocalAlbumAsset(albumId: unselectedAlbum.id, assetId: localAsset.id);
final remoteOnlyAsset = await ctx.newRemoteAsset(ownerId: user.id);
final result = await sut.getRemoteTrashCandidatesByAlbum({
remoteAsset.id: remoteDeletedAt,
remoteOnlyAsset.id: DateTime(2025, 6, 2),
});
expect(result.keys, equals({selectedAlbum.id}));
expect(result[selectedAlbum.id], hasLength(1));
expect(result[selectedAlbum.id]!.single.asset.id, localAsset.id);
expect(result[selectedAlbum.id]!.single.asset.remoteId, remoteAsset.id);
expect(result[selectedAlbum.id]!.single.remoteDeletedAt, remoteDeletedAt);
});
test('excludes assets that already have a trash sync state row', () async {
final user = await ctx.newUser();
final remoteDeletedAt = DateTime(2025, 6, 1);
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final pendingRemote = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: remoteDeletedAt);
final keptRemote = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: remoteDeletedAt);
final trashedRemote = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: remoteDeletedAt);
final pendingLocal = await ctx.newLocalAsset(checksum: pendingRemote.checksum);
final keptLocal = await ctx.newLocalAsset(checksum: keptRemote.checksum);
final trashedLocal = await ctx.newLocalAsset(checksum: trashedRemote.checksum);
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: pendingLocal.id);
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: keptLocal.id);
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: trashedLocal.id);
// pendingLocal already has a pending review row → still surfaces (caller
// can choose to skip it, but the repo doesn't filter). keptLocal and
// trashedLocal have terminal-state rows → suppressed because the join
// checks `localAssetTrashStateEntity.localAssetId IS NULL`.
Future<void> seed(String localAssetId, String checksum, TrashStateDecision decision) async {
await ctx.db
.into(ctx.db.trashSyncEntity)
.insert(
TrashSyncEntityCompanion.insert(
localAssetId: localAssetId,
checksum: Value(checksum),
decision: decision,
triggerSource: TrashTriggerSource.remoteSync,
remoteDeletedAt: Value(remoteDeletedAt),
name: '$localAssetId.jpg',
type: AssetType.image,
createdAt: Value(remoteDeletedAt),
updatedAt: Value(remoteDeletedAt),
),
);
}
// keptLocal and trashedLocal already decided — they should NOT be
// returned as new candidates. pendingLocal has no state row.
await seed(keptLocal.id, keptRemote.checksum, TrashStateDecision.kept);
await seed(trashedLocal.id, trashedRemote.checksum, TrashStateDecision.appTrashed);
final result = await sut.getRemoteTrashCandidatesByAlbum({
pendingRemote.id: remoteDeletedAt,
keptRemote.id: remoteDeletedAt,
trashedRemote.id: remoteDeletedAt,
});
expect(result.keys, equals({selectedAlbum.id}));
expect(result[selectedAlbum.id]!.map((item) => item.asset.id), [pendingLocal.id]);
});
});
group('getRemovalCandidates', () {
final cutoffDate = DateTime(2024, 1, 1);
final beforeCutoff = DateTime(2023, 12, 31);
+4 -1
View File
@@ -1,8 +1,9 @@
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
@@ -13,4 +14,6 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {}
class MockAuthRepository extends Mock implements AuthRepository {}
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
class MockTagService extends Mock implements TagService {}
+11 -54
View File
@@ -11,7 +11,6 @@ import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
@@ -25,7 +24,7 @@ void main() {
late MockDriftLocalAssetRepository localAssetRepository;
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockDriftTrashSyncRepository trashSyncRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
@@ -33,7 +32,6 @@ void main() {
late Drift db;
setUpAll(() async {
registerFallbackValue(LocalAssetStub.image1);
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
@@ -53,7 +51,7 @@ void main() {
localAssetRepository = MockDriftLocalAssetRepository();
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
trashSyncRepository = MockDriftTrashSyncRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
@@ -64,14 +62,11 @@ void main() {
localAssetRepository,
albumApiRepository,
remoteAlbumRepository,
trashSyncRepository,
trashedLocalAssetRepository,
assetMediaRepository,
downloadRepository,
tagService,
);
when(() => trashSyncRepository.recordUserManualTrash(any())).thenAnswer((_) async {});
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
});
tearDown(() async {
@@ -79,32 +74,34 @@ void main() {
});
group('ActionService.deleteLocal', () {
test('records user manual trash and deletes local asset row when Android trash handling is enabled', () async {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => trashSyncRepository.recordUserManualTrash(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
test('only deletes locally when Android trash handling is disabled', () async {
test('deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashSyncRepository.recordUserManualTrash(any()));
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
test('short-circuits when nothing was deleted', () async {
@@ -117,48 +114,8 @@ void main() {
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verifyNever(() => trashSyncRepository.recordUserManualTrash(any()));
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
});
// The detailed approve/reject/partial-success behaviour is owned by
// DriftTrashSyncRepository (see trash_sync_repository_test.dart
// for the state-machine tests). Here we only verify that
// ActionService delegates correctly — the HIGH atomicity bug from the
// original PR can't recur because the state-machine surface is a
// single transactional method.
group('ActionService.resolveRemoteTrash', () {
test('delegates "keep" decisions to DriftTrashSyncRepository.applyReviewDecision', () async {
when(
() => trashSyncRepository.applyReviewDecision(any(), keep: any(named: 'keep')),
).thenAnswer((_) async => (displayCount: 2, success: true));
final result = await sut.resolveRemoteTrash(['local-1', 'local-2'], keep: true);
expect(result, (displayCount: 2, success: true));
verify(() => trashSyncRepository.applyReviewDecision(['local-1', 'local-2'], keep: true)).called(1);
});
test('delegates "trash" decisions to DriftTrashSyncRepository.applyReviewDecision', () async {
when(
() => trashSyncRepository.applyReviewDecision(any(), keep: any(named: 'keep')),
).thenAnswer((_) async => (displayCount: 1, success: true));
final result = await sut.resolveRemoteTrash(['local-1'], keep: false);
expect(result, (displayCount: 1, success: true));
verify(() => trashSyncRepository.applyReviewDecision(['local-1'], keep: false)).called(1);
});
test('propagates partial-success results from DriftTrashSyncRepository unchanged', () async {
when(
() => trashSyncRepository.applyReviewDecision(any(), keep: any(named: 'keep')),
).thenAnswer((_) async => (displayCount: 1, success: false));
final result = await sut.resolveRemoteTrash(['local-1', 'local-2'], keep: false);
expect(result, (displayCount: 1, success: false));
});
});
}
@@ -91,7 +91,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -123,8 +122,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@@ -140,8 +138,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@@ -160,8 +157,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
@@ -178,8 +174,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@@ -196,8 +191,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@@ -216,8 +210,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isTrue);
@@ -234,8 +227,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -252,8 +244,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -270,8 +261,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -288,8 +278,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -308,8 +297,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
@@ -326,8 +314,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@@ -344,8 +331,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@@ -364,8 +350,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isTrue);
@@ -382,8 +367,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@@ -400,8 +384,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@@ -420,8 +403,7 @@ void main() {
isStacked: false,
currentAlbum: null,
advancedTroubleshooting: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue);
@@ -438,8 +420,7 @@ void main() {
currentAlbum: null,
isStacked: false,
advancedTroubleshooting: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse);
@@ -458,8 +439,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isTrue);
@@ -476,8 +456,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
@@ -552,8 +531,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
@@ -570,8 +548,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
@@ -608,8 +585,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.delete.shouldShow(context), isTrue);
@@ -628,8 +604,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
@@ -648,8 +623,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@@ -666,8 +640,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
@@ -683,8 +656,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@@ -703,8 +675,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.upload.shouldShow(context), isTrue);
@@ -723,8 +694,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
@@ -740,8 +710,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
@@ -939,8 +908,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
@@ -957,8 +925,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -975,8 +942,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -992,8 +958,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -1011,8 +976,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: true,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue);
@@ -1028,8 +992,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse);
@@ -1049,7 +1012,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: true,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1067,7 +1029,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1085,7 +1046,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1108,7 +1068,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
});
@@ -1128,8 +1087,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@@ -1143,8 +1101,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@@ -1174,8 +1131,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@@ -1199,7 +1155,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1221,7 +1176,6 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1241,7 +1195,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1262,7 +1215,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@@ -1277,7 +1229,6 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
+1 -1
View File
@@ -25,7 +25,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
+1 -1
View File
@@ -27,7 +27,7 @@
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@types/node": "^24.11.0",
"@types/node": "^24.12.4",
"esbuild": "^0.27.3",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
+1 -1
View File
@@ -24,7 +24,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"typescript": "^6.0.0"
}
}
+175 -175
View File
@@ -128,8 +128,8 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
'@types/pg':
specifier: ^8.15.1
version: 8.20.0
@@ -195,10 +195,10 @@ importers:
version: 5.2.1(encoding@0.1.13)
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/cli:
dependencies:
@@ -240,8 +240,8 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
'@vitest/coverage-v8':
specifier: ^4.0.0
version: 4.1.5(vitest@4.1.5)
@@ -286,10 +286,10 @@ importers:
version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)
vite:
specifier: ^8.0.0
version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.5)
@@ -333,8 +333,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1
'@types/node':
specifier: ^24.11.0
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
esbuild:
specifier: ^0.27.3
version: 0.27.4
@@ -352,8 +352,8 @@ importers:
version: 1.2.0
devDependencies:
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
typescript:
specifier: ^6.0.0
version: 6.0.3
@@ -524,7 +524,7 @@ importers:
version: 2.1.1
nest-commander:
specifier: ^3.16.0
version: 3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.2)(typescript@6.0.3)
version: 3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.4)(typescript@6.0.3)
nestjs-cls:
specifier: ^6.0.0
version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -609,7 +609,7 @@ importers:
version: 10.0.1(eslint@10.2.1(jiti@2.6.1))
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)
version: 11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(prettier@3.8.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
@@ -662,8 +662,8 @@ importers:
specifier: ^2.0.0
version: 2.1.0
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
'@types/nodemailer':
specifier: ^8.0.0
version: 8.0.0
@@ -690,7 +690,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^10.0.0
version: 10.2.1(jiti@2.6.1)
@@ -741,10 +741,10 @@ importers:
version: 1.5.9(@swc/core@1.15.30(@swc/helpers@0.5.21))(rollup@4.55.1)
vite-tsconfig-paths:
specifier: ^6.0.0
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
web:
dependencies:
@@ -5321,8 +5321,8 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
'@types/node@24.12.4':
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
@@ -12962,11 +12962,11 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/schematics-cli@19.2.24(@types/node@24.12.2)(chokidar@4.0.3)':
'@angular-devkit/schematics-cli@19.2.24(@types/node@24.12.4)(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
'@inquirer/prompts': 7.3.2(@types/node@24.12.2)
'@inquirer/prompts': 7.3.2(@types/node@24.12.4)
ansi-colors: 4.1.3
symbol-observable: 4.0.0
yargs-parser: 21.1.1
@@ -15476,143 +15476,143 @@ snapshots:
'@inquirer/ansi@1.0.2': {}
'@inquirer/checkbox@4.3.2(@types/node@24.12.2)':
'@inquirer/checkbox@4.3.2(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/confirm@5.1.21(@types/node@24.12.2)':
'@inquirer/confirm@5.1.21(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/core@10.3.2(@types/node@24.12.2)':
'@inquirer/core@10.3.2(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/editor@4.2.23(@types/node@24.12.2)':
'@inquirer/editor@4.2.23(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/expand@4.0.23(@types/node@24.12.2)':
'@inquirer/expand@4.0.23(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/external-editor@1.0.3(@types/node@24.12.2)':
'@inquirer/external-editor@1.0.3(@types/node@24.12.4)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/figures@1.0.15': {}
'@inquirer/input@4.3.1(@types/node@24.12.2)':
'@inquirer/input@4.3.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/number@3.0.23(@types/node@24.12.2)':
'@inquirer/number@3.0.23(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/password@4.0.23(@types/node@24.12.2)':
'@inquirer/password@4.0.23(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/prompts@7.10.1(@types/node@24.12.2)':
'@inquirer/prompts@7.10.1(@types/node@24.12.4)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.12.2)
'@inquirer/confirm': 5.1.21(@types/node@24.12.2)
'@inquirer/editor': 4.2.23(@types/node@24.12.2)
'@inquirer/expand': 4.0.23(@types/node@24.12.2)
'@inquirer/input': 4.3.1(@types/node@24.12.2)
'@inquirer/number': 3.0.23(@types/node@24.12.2)
'@inquirer/password': 4.0.23(@types/node@24.12.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.2)
'@inquirer/search': 3.2.2(@types/node@24.12.2)
'@inquirer/select': 4.4.2(@types/node@24.12.2)
'@inquirer/checkbox': 4.3.2(@types/node@24.12.4)
'@inquirer/confirm': 5.1.21(@types/node@24.12.4)
'@inquirer/editor': 4.2.23(@types/node@24.12.4)
'@inquirer/expand': 4.0.23(@types/node@24.12.4)
'@inquirer/input': 4.3.1(@types/node@24.12.4)
'@inquirer/number': 3.0.23(@types/node@24.12.4)
'@inquirer/password': 4.0.23(@types/node@24.12.4)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.4)
'@inquirer/search': 3.2.2(@types/node@24.12.4)
'@inquirer/select': 4.4.2(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/prompts@7.3.2(@types/node@24.12.2)':
'@inquirer/prompts@7.3.2(@types/node@24.12.4)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.12.2)
'@inquirer/confirm': 5.1.21(@types/node@24.12.2)
'@inquirer/editor': 4.2.23(@types/node@24.12.2)
'@inquirer/expand': 4.0.23(@types/node@24.12.2)
'@inquirer/input': 4.3.1(@types/node@24.12.2)
'@inquirer/number': 3.0.23(@types/node@24.12.2)
'@inquirer/password': 4.0.23(@types/node@24.12.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.2)
'@inquirer/search': 3.2.2(@types/node@24.12.2)
'@inquirer/select': 4.4.2(@types/node@24.12.2)
'@inquirer/checkbox': 4.3.2(@types/node@24.12.4)
'@inquirer/confirm': 5.1.21(@types/node@24.12.4)
'@inquirer/editor': 4.2.23(@types/node@24.12.4)
'@inquirer/expand': 4.0.23(@types/node@24.12.4)
'@inquirer/input': 4.3.1(@types/node@24.12.4)
'@inquirer/number': 3.0.23(@types/node@24.12.4)
'@inquirer/password': 4.0.23(@types/node@24.12.4)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.4)
'@inquirer/search': 3.2.2(@types/node@24.12.4)
'@inquirer/select': 4.4.2(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/rawlist@4.1.11(@types/node@24.12.2)':
'@inquirer/rawlist@4.1.11(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/search@3.2.2(@types/node@24.12.2)':
'@inquirer/search@3.2.2(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/select@4.4.2(@types/node@24.12.2)':
'@inquirer/select@4.4.2(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/type@3.0.10(@types/node@24.12.2)':
'@inquirer/type@3.0.10(@types/node@24.12.4)':
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@internationalized/date@3.12.1':
dependencies:
@@ -15644,7 +15644,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -16020,12 +16020,12 @@ snapshots:
bullmq: 5.76.1
tslib: 2.8.1
'@nestjs/cli@11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)':
'@nestjs/cli@11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(prettier@3.8.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics-cli': 19.2.24(@types/node@24.12.2)(chokidar@4.0.3)
'@inquirer/prompts': 7.10.1(@types/node@24.12.2)
'@angular-devkit/schematics-cli': 19.2.24(@types/node@24.12.4)(chokidar@4.0.3)
'@inquirer/prompts': 7.10.1(@types/node@24.12.4)
'@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)
ansis: 4.2.0
chokidar: 4.0.3
@@ -17359,7 +17359,7 @@ snapshots:
'@types/accepts@1.3.7':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/archiver@7.0.0':
dependencies:
@@ -17371,16 +17371,16 @@ snapshots:
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/bonjour@3.5.13':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/braces@3.0.5': {}
@@ -17402,21 +17402,21 @@ snapshots:
'@types/cli-progress@3.11.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/compression@1.8.1':
dependencies:
'@types/express': 5.0.6
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/content-disposition@0.5.9': {}
@@ -17433,11 +17433,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/express': 5.0.6
'@types/keygrip': 1.0.6
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/d3-array@3.2.2': {}
@@ -17564,13 +17564,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2': 1.15.5
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2': 1.15.5
'@types/dom-to-image@2.6.7': {}
@@ -17595,14 +17595,14 @@ snapshots:
'@types/express-serve-static-core@4.19.7':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -17628,7 +17628,7 @@ snapshots:
'@types/fluent-ffmpeg@2.1.28':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/geojson@7946.0.16': {}
@@ -17656,7 +17656,7 @@ snapshots:
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/inquirer@8.2.12':
dependencies:
@@ -17680,7 +17680,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/justified-layout@4.1.4': {}
@@ -17699,7 +17699,7 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.9
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/leaflet@1.9.21':
dependencies:
@@ -17729,7 +17729,7 @@ snapshots:
'@types/mock-fs@4.13.4':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ms@2.1.0': {}
@@ -17739,7 +17739,7 @@ snapshots:
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/node@17.0.45': {}
@@ -17747,7 +17747,7 @@ snapshots:
dependencies:
undici-types: 5.26.5
'@types/node@24.12.2':
'@types/node@24.12.4':
dependencies:
undici-types: 7.16.0
@@ -17758,13 +17758,13 @@ snapshots:
'@types/nodemailer@8.0.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/oidc-provider@9.5.0':
dependencies:
'@types/keygrip': 1.0.6
'@types/koa': 3.0.1
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/parse5@5.0.3': {}
@@ -17774,13 +17774,13 @@ snapshots:
'@types/pg@8.15.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/pg@8.20.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
pg-protocol: 1.13.0
pg-types: 2.2.0
@@ -17788,13 +17788,13 @@ snapshots:
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/prismjs@1.26.5': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/qs@6.14.0': {}
@@ -17823,24 +17823,24 @@ snapshots:
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/retry@0.12.2': {}
'@types/sax@1.2.7':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/semver@7.7.1': {}
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/send@1.2.1':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/serve-index@1.9.4':
dependencies:
@@ -17849,25 +17849,25 @@ snapshots:
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/send': 0.17.6
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2-streams@0.1.13':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -17878,7 +17878,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 24.12.2
'@types/node': 24.12.4
form-data: 4.0.5
'@types/supercluster@7.1.3':
@@ -17892,7 +17892,7 @@ snapshots:
'@types/through@0.0.33':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/trusted-types@2.0.7': {}
@@ -17908,7 +17908,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/yargs-parser@21.0.3': {}
@@ -18015,7 +18015,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -18030,7 +18030,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -18046,7 +18046,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4':
dependencies:
@@ -18065,21 +18065,21 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
@@ -19896,7 +19896,7 @@ snapshots:
engine.io@6.6.5:
dependencies:
'@types/cors': 2.8.19
'@types/node': 24.12.2
'@types/node': 24.12.4
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@@ -20315,7 +20315,7 @@ snapshots:
eval@0.1.8:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
require-like: 0.1.2
event-emitter@0.3.5:
@@ -20876,7 +20876,7 @@ snapshots:
happy-dom@20.9.0:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
@@ -21301,9 +21301,9 @@ snapshots:
inline-style-parser@0.2.7: {}
inquirer@8.2.7(@types/node@24.12.2):
inquirer@8.2.7(@types/node@24.12.4):
dependencies:
'@inquirer/external-editor': 1.0.3(@types/node@24.12.2)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.4)
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
@@ -21522,7 +21522,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 24.12.2
'@types/node': 24.12.4
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -21530,13 +21530,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -22777,7 +22777,7 @@ snapshots:
neo-async@2.6.2: {}
nest-commander@3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.2)(typescript@6.0.3):
nest-commander@3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.4)(typescript@6.0.3):
dependencies:
'@fig/complete-commander': 3.2.0(commander@11.1.0)
'@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)
@@ -22786,7 +22786,7 @@ snapshots:
'@types/inquirer': 8.2.12
commander: 11.1.0
cosmiconfig: 8.3.6(typescript@6.0.3)
inquirer: 8.2.7(@types/node@24.12.2)
inquirer: 8.2.7(@types/node@24.12.4)
transitivePeerDependencies:
- '@types/node'
- typescript
@@ -23939,7 +23939,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
long: 5.3.2
protobufjs@8.0.1:
@@ -23954,7 +23954,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
long: 5.3.2
protocol-buffers-schema@3.6.1: {}
@@ -25945,13 +25945,13 @@ snapshots:
- rollup
- supports-color
vite-node@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite-node@3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -25966,17 +25966,17 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@6.0.3)
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.4)
@@ -25985,7 +25985,7 @@ snapshots:
rollup: 4.55.1
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.32.0
@@ -25994,7 +25994,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -26002,7 +26002,7 @@ snapshots:
rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
esbuild: 0.28.0
fsevents: 2.3.3
jiti: 2.6.1
@@ -26034,13 +26034,13 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@4.1.5):
dependencies:
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -26058,12 +26058,12 @@ snapshots:
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.12.2
'@types/node': 24.12.4
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
@@ -26080,10 +26080,10 @@ snapshots:
- tsx
- yaml
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -26100,11 +26100,11 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.12.2
'@types/node': 24.12.4
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
+1 -1
View File
@@ -138,7 +138,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"@types/nodemailer": "^8.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",