Compare commits

...

1 Commits

Author SHA1 Message Date
shenlong-tanwen 653c4db355 feat(mobile): review remote-trashed assets before syncing deletions
Adds a "Review remote deletions" mode for trash-sync: when the server
marks a remote asset as deleted, the local copy is queued for user
review before being moved to the device trash. The existing auto-sync
mode (Android-only, requires MANAGE_MEDIA) keeps the silent-mirror
behavior. iOS gets the review surface only — auto-sync is hidden
because PhotoKit prompts on every batch, defeating the silent intent.

State lives on a single trash_sync_entity table keyed by local_asset_id
with three decisions (pendingReview / kept / appTrashed) and two
trigger sources (remoteSync / localUser). Both review-mode decisions
and auto-mode transitions are single-row column updates on the same
table, so the cross-repo atomicity bug from the original draft cannot
recur structurally.

Other shape choices:
- UI subscribes to watchPendingReviewCount() to surface a review-badge
  notification — no event-stream needed.
- recheckRemoteTrashCandidates() closes the durability gap from acked
  assetDeleteV1 events arriving before the local was hashed.
- Auto-restore is gated on TrashSyncMode.autoSync; review mode never
  fires OS-level trash or restore on its own.
- Predicates query backup-album selection dynamically via existsQuery,
  so assets in multiple selected albums aren't dropped during dedup.
- getAppTrashedRemotelyRestored joins trashed_local_asset_entity for
  album reconciliation (the asset leaves local_album_asset_entity after
  auto-trash but stays in the OS-trash mirror).
- Bucket queries use SQL GROUP BY date instead of Dart-side reduce;
  shared predicate subquery between bucket and asset paths.

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-05-20 02:09:15 +05:30
65 changed files with 19883 additions and 1071 deletions
-1
View File
@@ -13,4 +13,3 @@ jobs:
actions: read actions: read
contents: read contents: read
security-events: write security-events: write
secrets: inherit
+15 -2
View File
@@ -465,10 +465,14 @@
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]", "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_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_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_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_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
"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_off_subtitle": "Cloud trash changes are ignored",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]", "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_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting",
@@ -579,6 +583,11 @@
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud", "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": "Asset Offline",
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.", "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_restored_successfully": "Asset restored successfully",
"asset_skipped": "Skipped", "asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash", "asset_skipped_in_trash": "In trash",
@@ -597,6 +606,7 @@
"assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", "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_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_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", "assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
@@ -1366,6 +1376,7 @@
"keep_all": "Keep All", "keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.", "keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites", "keep_favorites": "Keep favorites",
"keep_in_trash": "Keep in trash",
"keep_on_device": "Keep on device", "keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep on this device", "keep_on_device_hint": "Select items to keep on this device",
"keep_this_delete_others": "Keep this, delete others", "keep_this_delete_others": "Keep this, delete others",
@@ -1685,6 +1696,7 @@
"obtainium_configurator": "Obtainium Configurator", "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", "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", "ocr": "OCR",
"off": "Off",
"official_immich_resources": "Official Immich Resources", "official_immich_resources": "Official Immich Resources",
"offline": "Offline", "offline": "Offline",
"offset": "Offset", "offset": "Offset",
@@ -1959,6 +1971,7 @@
"retry_upload": "Retry upload", "retry_upload": "Retry upload",
"review_duplicates": "Review duplicates", "review_duplicates": "Review duplicates",
"review_large_files": "Review large files", "review_large_files": "Review large files",
"review_out_of_sync_changes": "Review out-of-sync changes",
"role": "Role", "role": "Role",
"role_editor": "Editor", "role_editor": "Editor",
"role_viewer": "Viewer", "role_viewer": "Viewer",
@@ -0,0 +1,181 @@
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,6 +553,10 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun 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> fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object { companion object {
@@ -747,6 +751,78 @@ interface NativeSyncApi {
channel.setMessageHandler(null) 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 { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) { if (api != null) {
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -39,10 +41,11 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase" private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private var hashTask: Job? = null private var hashTask: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object { companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16 private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -448,6 +451,36 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask = null 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 // This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER") @Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> { fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
File diff suppressed because it is too large Load Diff
+69
View File
@@ -537,6 +537,10 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func 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] func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
} }
@@ -721,6 +725,71 @@ class NativeSyncApiSetup {
} else { } else {
getTrashedAssetsChannel.setMessageHandler(nil) 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 let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) ? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue) : FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
+16
View File
@@ -382,6 +382,22 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
func getTrashedAssets() throws -> [String: [PlatformAsset]] { func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
} }
func 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> { private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album // Ensure to actually getting all assets for the Recents album
@@ -31,7 +31,7 @@ class LocalAsset extends BaseAsset {
this.adjustmentTime, this.adjustmentTime,
this.latitude, this.latitude,
this.longitude, this.longitude,
required super.isEdited, super.isEdited = false,
}) : remoteAssetId = remoteId; }) : remoteAssetId = remoteId;
@override @override
@@ -23,6 +23,7 @@ class RemoteAsset extends BaseAsset {
required super.createdAt, required super.createdAt,
required super.updatedAt, required super.updatedAt,
this.uploadedAt, this.uploadedAt,
this.deletedAt,
super.width, super.width,
super.height, super.height,
super.durationMs, super.durationMs,
@@ -32,7 +33,6 @@ class RemoteAsset extends BaseAsset {
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
required super.isEdited, required super.isEdited,
this.deletedAt,
}) : localAssetId = localId; }) : localAssetId = localId;
@override @override
@@ -62,6 +62,7 @@ class RemoteAsset extends BaseAsset {
createdAt: $createdAt, createdAt: $createdAt,
updatedAt: $updatedAt, updatedAt: $updatedAt,
uploadedAt: ${uploadedAt ?? "<NA>"}, uploadedAt: ${uploadedAt ?? "<NA>"},
deletedAt: ${deletedAt ?? "<NA>"},
width: ${width ?? "<NA>"}, width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"}, height: ${height ?? "<NA>"},
durationMs: ${durationMs ?? "<NA>"}, durationMs: ${durationMs ?? "<NA>"},
@@ -89,6 +90,7 @@ class RemoteAsset extends BaseAsset {
ownerId == other.ownerId && ownerId == other.ownerId &&
thumbHash == other.thumbHash && thumbHash == other.thumbHash &&
visibility == other.visibility && visibility == other.visibility &&
deletedAt == other.deletedAt &&
stackId == other.stackId && stackId == other.stackId &&
uploadedAt == other.uploadedAt && uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt; deletedAt == other.deletedAt;
@@ -102,6 +104,7 @@ class RemoteAsset extends BaseAsset {
localId.hashCode ^ localId.hashCode ^
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode ^ visibility.hashCode ^
deletedAt.hashCode ^
stackId.hashCode ^ stackId.hashCode ^
uploadedAt.hashCode ^ uploadedAt.hashCode ^
deletedAt.hashCode; deletedAt.hashCode;
@@ -116,6 +119,7 @@ class RemoteAsset extends BaseAsset {
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt, DateTime? uploadedAt,
DateTime? deletedAt,
int? width, int? width,
int? height, int? height,
int? durationMs, int? durationMs,
@@ -125,7 +129,6 @@ class RemoteAsset extends BaseAsset {
String? livePhotoVideoId, String? livePhotoVideoId,
String? stackId, String? stackId,
bool? isEdited, bool? isEdited,
DateTime? deletedAt,
}) { }) {
return RemoteAsset( return RemoteAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -137,6 +140,7 @@ class RemoteAsset extends BaseAsset {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt, uploadedAt: uploadedAt ?? this.uploadedAt,
deletedAt: deletedAt ?? this.deletedAt,
width: width ?? this.width, width: width ?? this.width,
height: height ?? this.height, height: height ?? this.height,
durationMs: durationMs ?? this.durationMs, durationMs: durationMs ?? this.durationMs,
@@ -146,7 +150,6 @@ class RemoteAsset extends BaseAsset {
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId, stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited, isEdited: isEdited ?? this.isEdited,
deletedAt: deletedAt ?? this.deletedAt,
); );
} }
} }
@@ -0,0 +1,8 @@
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,6 +18,8 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013), syncMigrationStatus<String>._(1013),
reviewOutOfSyncChangesAndroid<bool>._(1014),
// Legacy keys that have been migrated to the new metadata store // Legacy keys that have been migrated to the new metadata store
legacyBackupRequireCharging<bool>._(7), legacyBackupRequireCharging<bool>._(7),
legacyBackupTriggerDelay<int>._(8), legacyBackupTriggerDelay<int>._(8),
@@ -4,15 +4,12 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_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/asset/base_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/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -23,35 +20,29 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager; final DriftTrashSyncRepository _trashSyncRepository;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
LocalSyncService({ LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager, required DriftTrashSyncRepository trashSyncRepository,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository, _localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager, _trashSyncRepository = trashSyncRepository,
_storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi; _nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async { Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
try { try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { if (CurrentPlatform.isAndroid) {
final hasPermission = await _localFilesManager.hasManageMediaPermission(); await _syncTrashedAssets();
if (hasPermission) { await _trashSyncRepository.syncRestoresForRevivedAssets();
await _syncTrashedAssets();
} else {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
}
} }
await _trashSyncRepository.recheckRemoteTrashCandidates();
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
// final assets = await _localAssetRepository.getEmptyCloudIdAssets(); // final assets = await _localAssetRepository.getEmptyCloudIdAssets();
@@ -60,7 +51,11 @@ class LocalSyncService {
if (full || await _nativeSyncApi.shouldFullSync()) { if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}"); _log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync(); await fullSync();
if (CurrentPlatform.isAndroid) {
await _cleanupTrashSync();
}
return;
} }
final delta = await _nativeSyncApi.getMediaChanges(); final delta = await _nativeSyncApi.getMediaChanges();
@@ -82,13 +77,13 @@ class LocalSyncService {
); );
final dbAlbums = await _localAlbumRepository.getAll(); 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) { if (CurrentPlatform.isAndroid) {
for (final album in dbAlbums) { for (final album in dbAlbums) {
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id); final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds); await _localAlbumRepository.syncDeletes(album.id, deviceIds);
} }
await _cleanupTrashSync();
} }
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
@@ -115,6 +110,13 @@ class LocalSyncService {
} }
} }
Future<void> _cleanupTrashSync() async {
final deleted = await _trashSyncRepository.cleanup();
if (deleted > 0) {
_log.fine("cleanup TrashState, deleted: $deleted");
}
}
Future<void> fullSync() async { Future<void> fullSync() async {
try { try {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
@@ -362,7 +364,7 @@ class LocalSyncService {
@visibleForTesting @visibleForTesting
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async { Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
if (trashedAssetMap.isEmpty) { if (trashedAssetMap.isEmpty) {
_log.info("syncTrashedAssets, No trashed assets found"); _log.fine("syncTrashedAssets, No trashed assets found");
} }
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand( final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key), (entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
@@ -370,30 +372,6 @@ class LocalSyncService {
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}"); _log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets); 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");
}
} }
} }
@@ -438,7 +416,6 @@ extension PlatformToLocalAsset on PlatformAsset {
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true), adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
isEdited: false,
); );
} }
@@ -3,18 +3,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; 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/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -27,15 +22,14 @@ enum SyncMigrationTask {
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column. v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
} }
typedef _RemoteAssetTrashState = ({String id, DateTime? deletedAt, String? checksum});
class SyncStreamService { class SyncStreamService {
final Logger _logger = Logger('SyncStreamService'); final Logger _logger = Logger('SyncStreamService');
final SyncApiRepository _syncApiRepository; final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository; final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftTrashSyncRepository _trashSyncRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository; final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api; final ApiService _api;
final bool Function()? _cancelChecker; final bool Function()? _cancelChecker;
@@ -43,19 +37,13 @@ class SyncStreamService {
SyncStreamService({ SyncStreamService({
required SyncApiRepository syncApiRepository, required SyncApiRepository syncApiRepository,
required SyncStreamRepository syncStreamRepository, required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftTrashSyncRepository trashSyncRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository, required SyncMigrationRepository syncMigrationRepository,
required ApiService api, required ApiService api,
bool Function()? cancelChecker, bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository, }) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository, _syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository, _trashSyncRepository = trashSyncRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository, _syncMigrationRepository = syncMigrationRepository,
_api = api, _api = api,
_cancelChecker = cancelChecker; _cancelChecker = cancelChecker;
@@ -200,22 +188,24 @@ class SyncStreamService {
case SyncEntityType.assetV1: case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>(); final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets); await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { await _handleRemoteAssetTrashState(
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList()); remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
} );
return; return;
case SyncEntityType.assetV2: case SyncEntityType.assetV2:
final remoteSyncAssets = data.cast<SyncAssetV2>(); final remoteSyncAssets = data.cast<SyncAssetV2>();
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets); await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { await _handleRemoteAssetTrashState(
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList()); remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
} );
return; return;
case SyncEntityType.assetDeleteV1: case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>(); final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { final now = DateTime.now();
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList()); final remoteDeletedAtByRemoteId = Map<String, DateTime>.fromEntries(
} remoteSyncAssets.map((e) => MapEntry(e.assetId, now)),
);
await _trashSyncRepository.recordRemoteTrash(remoteDeletedAtByRemoteId);
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets); return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
case SyncEntityType.assetExifV1: case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast()); return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -486,58 +476,17 @@ class SyncStreamService {
} }
} }
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async { Future<void> _handleRemoteAssetTrashState(Iterable<_RemoteAssetTrashState> remoteSyncAssets) async {
if (remoteIds.isEmpty) { final deleted = <String, DateTime>{};
return Future.value(); final aliveChecksums = <String>[];
} else { for (final e in remoteSyncAssets) {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds); if (e.deletedAt != null) {
if (localAssetsToTrash.isNotEmpty) { deleted[e.id] = e.deletedAt!;
await _trashLocalAssets(localAssetsToTrash); } else if (e.checksum != null) {
} else { aliveChecksums.add(e.checksum!);
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
} }
} }
} await _trashSyncRepository.recordRemoteTrash(deleted);
await _trashSyncRepository.recordRemoteRestore(aliveChecksums);
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;
}
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 _handleRemoteDeleted(remoteIds);
} }
} }
@@ -35,6 +35,7 @@ enum TimelineOrigin {
albumActivities, albumActivities,
folder, folder,
recentlyAdded, recentlyAdded,
syncTrash,
} }
class TimelineFactory { class TimelineFactory {
@@ -69,6 +70,8 @@ class TimelineFactory {
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy)); 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 archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy)); TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));
@@ -0,0 +1,52 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
enum TrashStateDecision {
// do not change this order!
pendingReview,
kept,
appTrashed,
}
enum TrashTriggerSource {
// do not change this order!
remoteSync,
localUser,
}
@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 LocalAssetEntity {
const TrashSyncEntity();
IntColumn get decision => intEnum<TrashStateDecision>()();
IntColumn get triggerSource => intEnum<TrashTriggerSource>()();
DateTimeColumn get remoteDeletedAt => dateTime().nullable()();
DateTimeColumn get decidedAt => dateTime().withDefault(currentDateAndTime)();
}
extension TrashSyncEntityDataDomainExtension on TrashSyncEntityData {
LocalAsset toLocalAsset() => LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationMs: durationMs,
isFavorite: isFavorite,
height: height,
width: width,
orientation: orientation,
playbackStyle: playbackStyle,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
cloudId: iCloudId,
);
}
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,7 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
enum TrashOrigin { enum TrashOrigin {
// do not change this order! // do not change this order!
@@ -13,23 +12,13 @@ enum TrashOrigin {
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class TrashedLocalAssetEntity extends LocalAssetEntity {
const TrashedLocalAssetEntity(); const TrashedLocalAssetEntity();
TextColumn get id => text()();
TextColumn get albumId => text()(); TextColumn get albumId => text()();
TextColumn get checksum => text().nullable()();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
IntColumn get orientation => integer().withDefault(const Constant(0))();
IntColumn get source => intEnum<TrashOrigin>()(); IntColumn get source => intEnum<TrashOrigin>()();
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
@override @override
Set<Column> get primaryKey => {id, albumId}; Set<Column> get primaryKey => {id, albumId};
} }
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.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/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.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/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
@@ -56,6 +57,7 @@ import 'package:logging/logging.dart';
TrashedLocalAssetEntity, TrashedLocalAssetEntity,
AssetEditEntity, AssetEditEntity,
MetadataEntity, MetadataEntity,
TrashSyncEntity,
], ],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
) )
@@ -98,7 +100,7 @@ class Drift extends $Drift {
} }
@override @override
int get schemaVersion => 26; int get schemaVersion => 27;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -276,6 +278,16 @@ class Drift extends $Drift {
from25To26: (m, v26) async { from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt); await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
}, },
from26To27: (m, v27) async {
await m.create(v27.trashSyncEntity);
await m.createIndex(v27.idxTrashSyncDecision);
await m.createIndex(v27.idxTrashSyncChecksum);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.iCloudId);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.adjustmentTime);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.latitude);
await m.addColumn(v27.trashedLocalAssetEntity, v27.trashedLocalAssetEntity.longitude);
},
), ),
); );
@@ -45,9 +45,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
as i21; as i21;
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
as i22; as i22;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i23; as i23;
import 'package:drift/internal/modular.dart' as i24; import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i24;
import 'package:drift/internal/modular.dart' as i25;
abstract class $Drift extends i0.GeneratedDatabase { abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e); $Drift(i0.QueryExecutor e) : super(e);
@@ -94,9 +96,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable( late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
this, this,
); );
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer( late final i23.$TrashSyncEntityTable trashSyncEntity = i23
.$TrashSyncEntityTable(this);
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
this, this,
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new); ).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
@override @override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables => Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>(); allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -133,6 +137,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
trashedLocalAssetEntity, trashedLocalAssetEntity,
assetEditEntity, assetEditEntity,
metadataEntity, metadataEntity,
trashSyncEntity,
i10.idxPartnerSharedWithId, i10.idxPartnerSharedWithId,
i11.idxLatLng, i11.idxLatLng,
i11.idxRemoteExifCity, i11.idxRemoteExifCity,
@@ -145,6 +150,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
i20.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum, i20.idxTrashedLocalAssetAlbum,
i21.idxAssetEditAssetId, i21.idxAssetEditAssetId,
i23.idxTrashSyncDecision,
i23.idxTrashSyncChecksum,
]; ];
@override @override
i0.StreamQueryUpdateRules i0.StreamQueryUpdateRules
@@ -397,4 +404,6 @@ class $DriftManager {
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity); i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$MetadataEntityTableTableManager get metadataEntity => i22.$$MetadataEntityTableTableManager get metadataEntity =>
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity); i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
i23.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
i23.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
} }
@@ -13539,6 +13539,714 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
$customConstraints: 'NULL', $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 Shape51 trashedLocalAssetEntity = Shape51(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_205,
_column_206,
],
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 Shape52 trashSyncEntity = Shape52(
source: i0.VersionedTable(
entityName: 'trash_sync_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_213,
_column_214,
_column_215,
_column_216,
],
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 id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! 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<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
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 albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! 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<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
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<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 remoteDeletedAt =>
columnsByName['remote_deleted_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get decidedAt =>
columnsByName['decided_at']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_213(String aliasedName) =>
i1.GeneratedColumn<int>(
'decision',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_214(String aliasedName) =>
i1.GeneratedColumn<int>(
'trigger_source',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_215(String aliasedName) =>
i1.GeneratedColumn<String>(
'remote_deleted_at',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<String> _column_216(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({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -13565,6 +14273,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24, 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, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26, required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -13693,6 +14402,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from25To26(migrator, schema); await from25To26(migrator, schema);
return 26; return 26;
case 26:
final schema = Schema27(database: database);
final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema);
return 27;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -13725,6 +14439,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24, 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, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26, required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -13752,5 +14467,6 @@ i1.OnUpgrade stepByStep({
from23To24: from23To24, from23To24: from23To24,
from24To25: from24To25, from24To25: from24To25,
from25To26: from25To26, from25To26: from25To26,
from26To27: from26To27,
), ),
); );
@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/enums.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/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.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/local_asset.entity.drift.dart';
@@ -109,41 +110,70 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get(); return query.map((localAlbum) => localAlbum.toDto()).get();
} }
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async { Future<List<RemoteDeletedLocalAsset>> getRemoteTrashCandidates(
if (remoteIds.isEmpty) { Map<String, DateTime> remoteDeletedAtByRemoteId,
return {}; ) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
return const [];
} }
final result = <String, List<LocalAsset>>{}; final byLocalId = <String, RemoteDeletedLocalAsset>{};
for (final slice in remoteDeletedAtByRemoteId.keys.toSet().slices(kDriftMaxChunk)) {
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) { final rows = await _remoteTrashCandidatesQuery(slice).get();
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
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) { for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; final assetData = row.readTable(_db.localAssetEntity);
final asset = row.readTable(_db.localAssetEntity).toDto(); final remoteId = row.read(_db.remoteAssetEntity.id)!;
(result[albumId] ??= <LocalAsset>[]).add(asset); byLocalId.putIfAbsent(
assetData.id,
() => RemoteDeletedLocalAsset(
asset: assetData.toDto(remoteId: remoteId),
remoteDeletedAt: remoteDeletedAtByRemoteId[remoteId]!,
),
);
} }
} }
return result; return byLocalId.values.toList();
}
JoinedSelectStatement<HasResultSet, dynamic> _remoteTrashCandidatesQuery(List<String> remoteIdSlice) {
return _db.select(_db.localAssetEntity).join([
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
leftOuterJoin(
_db.trashSyncEntity,
_db.localAssetEntity.id.equalsExp(_db.trashSyncEntity.id),
useColumns: false,
),
])
..addColumns([_db.remoteAssetEntity.id])
..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(remoteIdSlice) &
_db.trashSyncEntity.id.isNull(),
);
}
Future<Map<String, DateTime>> getRemotelyDeletedRemoteIds() async {
final rows =
await (_db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.id, _db.remoteAssetEntity.deletedAt])
..where(_db.remoteAssetEntity.deletedAt.isNotNull()))
.get();
return {for (final r in rows) r.read(_db.remoteAssetEntity.id)!: r.read(_db.remoteAssetEntity.deletedAt)!};
} }
Future<RemovalCandidatesResult> getRemovalCandidates( Future<RemovalCandidatesResult> getRemovalCandidates(
@@ -214,6 +244,20 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
Future<List<LocalAsset>> getByIds(Iterable<String> ids) async {
final assetIds = ids.toSet();
if (assetIds.isEmpty) {
return const [];
}
final assets = <LocalAsset>[];
for (final slice in assetIds.slices(kDriftMaxChunk)) {
final query = _db.localAssetEntity.select()..where((row) => row.id.isIn(slice));
final rows = await query.map((row) => row.toDto()).get();
assets.addAll(rows);
}
return assets;
}
Future<void> reconcileHashesFromCloudId() async { Future<void> reconcileHashesFromCloudId() async {
await _db.customUpdate( await _db.customUpdate(
''' '''
@@ -4,12 +4,14 @@ import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.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/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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.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/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@@ -346,6 +348,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
joinLocal: true, 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( TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) => filter: (row) =>
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive), row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
@@ -678,6 +686,58 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
} }
Stream<List<Bucket>> _watchTrashSyncBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
throw UnsupportedError("GroupAssetsBy.none is not supported for watchTrashSyncBucket");
}
final assetCountExp = _db.localAssetEntity.id.count();
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy, toLocal: true);
final query = _db.localAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..where(_db.localAssetEntity.id.isInQuery(_pendingTrashSyncIdsSubquery()))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}
Future<List<BaseAsset>> _getToTrashSyncBucketAssets({required int offset, required int count}) {
final query = _db.localAssetEntity.select()
..where((row) => row.id.isInQuery(_pendingTrashSyncIdsSubquery()))
..orderBy([(row) => OrderingTerm.desc(row.createdAt), (row) => OrderingTerm.asc(row.id)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
}
JoinedSelectStatement<HasResultSet, dynamic> _pendingTrashSyncIdsSubquery() {
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.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
return _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.id])
..where(
_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.pendingReview) & existsQuery(selectedAlbumAssets),
);
}
} }
List<Bucket> _generateBuckets(int count) { List<Bucket> _generateBuckets(int count) {
@@ -0,0 +1,448 @@
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';
enum TrashSyncMode { off, autoSync, review }
typedef RemoteTrashResolveResult = ({int displayCount, bool success});
class TrashSyncCandidate {
final String localAssetId;
final String? checksum;
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.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,
});
}
class DriftTrashSyncRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftTrashSyncRepository');
final Drift _db;
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
DriftTrashSyncRepository(this._db, this._localAssetRepository, this._assetMediaRepository) : super(_db);
TrashSyncMode get mode {
if (Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false)) {
return TrashSyncMode.review;
}
if (Store.get(StoreKey.manageLocalMediaAndroid, false)) {
return TrashSyncMode.autoSync;
}
return TrashSyncMode.off;
}
Future<void> recordRemoteTrash(Map<String, DateTime> remoteDeletedAtByRemoteId) async {
if (remoteDeletedAtByRemoteId.isEmpty) {
return;
}
final currentMode = mode;
if (currentMode == TrashSyncMode.off) {
return;
}
final candidates = await _localAssetRepository.getRemoteTrashCandidates(remoteDeletedAtByRemoteId);
if (candidates.isEmpty) {
_logger.fine('No local assets matched remote-delete batch of ${remoteDeletedAtByRemoteId.length}');
return;
}
final newCandidates = candidates.map(_candidateFrom).toList();
if (currentMode == TrashSyncMode.autoSync && await _canMoveLocalMediaToTrash()) {
final ids = candidates.map((c) => c.asset.id).toList();
_logger.info('Auto-trashing ${ids.length} local assets');
final movedIds = (await _assetMediaRepository.deleteAll(ids)).toSet();
await upsertCandidates(newCandidates);
if (movedIds.isNotEmpty) {
await markDecision(movedIds, TrashStateDecision.appTrashed);
}
return;
}
await upsertCandidates(newCandidates);
}
Future<void> recheckRemoteTrashCandidates() async {
if (mode == TrashSyncMode.off) {
return;
}
final deleted = await _localAssetRepository.getRemotelyDeletedRemoteIds();
if (deleted.isEmpty) {
return;
}
await recordRemoteTrash(deleted);
}
Future<void> recordRemoteRestore(Iterable<String> aliveRemoteChecksums) async {
final affected = await deleteForRestoredRemotes(aliveRemoteChecksums);
if (affected.isEmpty || mode != TrashSyncMode.autoSync) {
return;
}
final wereAppTrashed = affected.where((r) => r.decision == TrashStateDecision.appTrashed).toList();
if (wereAppTrashed.isEmpty) {
return;
}
if (!CurrentPlatform.isAndroid || !await _hasManageMediaPermission('restore from trash')) {
return;
}
final localAssets = wereAppTrashed.map((r) => r.toLocalAsset()).toList();
await _assetMediaRepository.restoreAssetsFromTrash(localAssets);
}
Future<void> syncRestoresForRevivedAssets() async {
if (mode != TrashSyncMode.autoSync) {
return;
}
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;
}
await deleteByAssetIds(restoredIds);
}
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);
}
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,
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);
}
TrashSyncCandidate _candidateFrom(RemoteDeletedLocalAsset candidate) {
final asset = candidate.asset;
return TrashSyncCandidate(
localAssetId: asset.id,
checksum: asset.checksum,
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;
}
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(
id: c.localAssetId,
checksum: Value(c.checksum),
decision: TrashStateDecision.pendingReview,
triggerSource: c.triggerSource,
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),
),
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.id.isIn(slice),
);
}
});
}
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.id.isIn(slice));
}
});
}
Future<List<TrashSyncEntityData>> getAppTrashedRemotelyRestored() async {
final selectedTrashedAlbums =
_db.trashedLocalAssetEntity.selectOnly().join([
innerJoin(
_db.localAlbumEntity,
_db.trashedLocalAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
])
..addColumns([_db.trashedLocalAssetEntity.id])
..where(
_db.trashedLocalAssetEntity.id.equalsExp(_db.trashSyncEntity.id) &
_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() &
existsQuery(selectedTrashedAlbums),
))
.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.id])
..where(_db.trashSyncEntity.decision.equalsValue(TrashStateDecision.appTrashed)))
.get();
return rows.map((r) => r.read(_db.trashSyncEntity.id)!).toSet();
}
Stream<int> watchPendingReviewCount() {
final countExpr = _db.trashSyncEntity.id.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.id])
..where(
_db.trashSyncEntity.id.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.id])
..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.id) &
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected),
);
return existsQuery(selectedAlbumQ);
}
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.id.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/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_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/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.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.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
@@ -57,9 +57,6 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset()); return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset());
} }
/// Applies resulted snapshot of trashed assets:
/// - upserts incoming rows
/// - deletes rows that are not present in the snapshot
Future<void> processTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async { Future<void> processTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async {
if (trashedAssets.isEmpty) { if (trashedAssets.isEmpty) {
await _db.delete(_db.trashedLocalAssetEntity).go(); await _db.delete(_db.trashedLocalAssetEntity).go();
@@ -86,7 +83,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
isFavorite: Value(item.asset.isFavorite), isFavorite: Value(item.asset.isFavorite),
orientation: Value(item.asset.orientation), orientation: Value(item.asset.orientation),
playbackStyle: Value(item.asset.playbackStyle), playbackStyle: Value(item.asset.playbackStyle),
source: TrashOrigin.localSync, source: .localSync,
); );
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>( batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
@@ -104,9 +101,11 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get(); )..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
final idToDelete = existingIds.where((id) => !assetIds.contains(id)); final idToDelete = existingIds.where((id) => !assetIds.contains(id));
for (final slice in idToDelete.slices(kDriftMaxChunk)) { await _db.batch((batch) {
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go(); for (final slice in idToDelete.slices(kDriftMaxChunk)) {
} (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
}
});
} }
}); });
} }
@@ -125,7 +124,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0); .map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
} }
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async { Future<void> trashLocalAssets(Map<String, Iterable<RemoteDeletedLocalAsset>> assetsByAlbums) async {
if (assetsByAlbums.isEmpty) { if (assetsByAlbums.isEmpty) {
return Future.value(); return Future.value();
} }
@@ -134,7 +133,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
final idToDelete = <String>{}; final idToDelete = <String>{};
for (final entry in assetsByAlbums.entries) { for (final entry in assetsByAlbums.entries) {
for (final asset in entry.value) { for (final record in entry.value) {
final asset = record.asset;
idToDelete.add(asset.id); idToDelete.add(asset.id);
companions.add( companions.add(
TrashedLocalAssetEntityCompanion( TrashedLocalAssetEntityCompanion(
@@ -264,32 +264,6 @@ 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 //attempt to reuse existing checksums
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async { Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
final localChecksumById = <String, String>{}; final localChecksumById = <String, String>{};
+76
View File
@@ -654,6 +654,82 @@ class NativeSyncApi {
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>(); return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
} }
Future<bool> 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 { Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final pigeonVar_channelName = final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
@@ -0,0 +1,60 @@
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(),
),
);
}
@@ -0,0 +1,65 @@
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),
);
}
}
@@ -0,0 +1,98 @@
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,17 +2,22 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/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/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/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/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_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_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/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/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/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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/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/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
@@ -39,29 +44,50 @@ class ViewerBottomBar extends ConsumerWidget {
final serverInfo = ref.watch(serverInfoProvider); final serverInfo = ref.watch(serverInfoProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
final originalTheme = context.themeData; final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote) if (isInTrash && isOwner && asset.hasRemote && !isSyncTrashTimeline)
const RestoreActionButton(source: ActionSource.viewer) const RestoreActionButton(source: ActionSource.viewer)
else else
const ShareActionButton(source: ActionSource.viewer), 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 (!isInLockedView) ...[
if (!isInTrash) ...[ if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
// edit sync was added in 2.6.0 // edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(), const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
], ],
if (isOwner) ...[ if (isOwner) ...[
if (asset.isLocalOnly) if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer) const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed) else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true) const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
], ],
], ],
]; ];
@@ -112,4 +138,15 @@ 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,6 +9,7 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -35,6 +36,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final currentAlbum = ref.watch(currentRemoteAlbumProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final isWaitingForTrashApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actionContext = ActionButtonContext( final actionContext = ActionButtonContext(
asset: asset, asset: asset,
@@ -48,6 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
source: ActionSource.viewer, source: ActionSource.viewer,
isCasting: isCasting, isCasting: isCasting,
timelineOrigin: timelineOrigin, timelineOrigin: timelineOrigin,
isWaitingForTrashApproval: isWaitingForTrashApproval,
); );
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/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/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/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@@ -14,6 +15,8 @@ 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/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/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/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -47,6 +50,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData; final originalTheme = context.themeData;
final isWaitingForSyncApproval =
ref.read(timelineServiceProvider).origin == TimelineOrigin.syncTrash ||
ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
final actions = <Widget>[ final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared) if (album != null && album.isActivityEnabled && album.isShared)
@@ -63,9 +70,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
}, },
), ),
if (asset.hasRemote && isOwner && !asset.isFavorite) if (asset.hasRemote && isOwner && !asset.isFavorite && !isWaitingForSyncApproval)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true), const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite) if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true), const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme), ViewerKebabMenu(originalTheme: originalTheme),
@@ -0,0 +1,38 @@
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,3 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
final appSettingsServiceProvider = Provider((_) => const AppSettingsService()); 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,6 +550,21 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: ids.length, success: false, error: error.toString()); 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> { extension on Iterable<RemoteAsset> {
@@ -11,8 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider))); final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -20,10 +19,7 @@ final syncStreamServiceProvider = Provider(
(ref) => SyncStreamService( (ref) => SyncStreamService(
syncApiRepository: ref.watch(syncApiRepositoryProvider), syncApiRepository: ref.watch(syncApiRepositoryProvider),
syncStreamRepository: ref.watch(syncStreamRepositoryProvider), syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository), trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider), syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider), api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider), cancelChecker: ref.watch(cancellationProvider),
@@ -39,8 +35,7 @@ final localSyncServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository), localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider), trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider),
), ),
); );
@@ -1,12 +1,45 @@
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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}); typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>((ref) {
return DriftTrashSyncRepository(
ref.watch(driftProvider),
ref.watch(localAssetRepository),
ref.watch(assetMediaRepositoryProvider),
);
});
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) { final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
final repo = ref.watch(trashedLocalAssetRepository); final repo = ref.watch(trashedLocalAssetRepository);
final total$ = repo.watchCount(); final total$ = repo.watchCount();
final hashed$ = repo.watchHashedCount(); final hashed$ = repo.watchHashedCount();
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1])); 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,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider))); final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
class AssetMediaRepository { class AssetMediaRepository {
final AssetApiRepository _assetApiRepository; final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository"); static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository); const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
Future<bool> _androidSupportsTrash() async { Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
@@ -45,6 +50,54 @@ class AssetMediaRepository {
return PhotoManager.editor.deleteWithIds(ids); 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 { Future<AssetEntity?> get(String id) async {
final entity = await AssetEntity.fromId(id); final entity = await AssetEntity.fromId(id);
return entity; return entity;
@@ -1,51 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/services/local_files_manager.service.dart';
import 'package:logging/logging.dart';
final localFilesManagerRepositoryProvider = Provider(
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
);
class LocalFilesManagerRepository {
LocalFilesManagerRepository(this._service);
final Logger _logger = Logger('LocalFilesManagerRepo');
final LocalFilesManagerService _service;
Future<bool> moveToTrash(List<String> mediaUrls) async {
return await _service.moveToTrash(mediaUrls);
}
Future<bool> restoreFromTrash(String fileName, int type) async {
return await _service.restoreFromTrash(fileName, type);
}
Future<bool> requestManageMediaPermission() async {
return await _service.requestManageMediaPermission();
}
Future<bool> hasManageMediaPermission() async {
return await _service.hasManageMediaPermission();
}
Future<bool> manageMediaPermission() async {
return await _service.manageMediaPermission();
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
try {
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
} catch (e) {
_logger.warning("Restoring failure: $e");
}
}
return restoredIds;
}
}
+2
View File
@@ -63,6 +63,7 @@ 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_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.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_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/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart'; import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
@@ -163,6 +164,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashRoute.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: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]), AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),
+16
View File
@@ -1158,6 +1158,22 @@ 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 /// generated route for
/// [DriftUploadDetailPage] /// [DriftUploadDetailPage]
class DriftUploadDetailRoute extends PageRouteInfo<void> { class DriftUploadDetailRoute extends PageRouteInfo<void> {
+14 -7
View File
@@ -8,14 +8,15 @@ 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/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.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/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/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.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_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart';
@@ -34,7 +35,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(localAssetRepository), ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider), ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository), ref.watch(remoteAlbumRepository),
ref.watch(trashedLocalAssetRepository), ref.watch(trashSyncRepositoryProvider),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider), ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider), ref.watch(tagServiceProvider),
@@ -47,7 +48,7 @@ class ActionService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository; final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashSyncRepository _trashSyncRepository;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository; final DownloadRepository _downloadRepository;
final TagService _tagService; final TagService _tagService;
@@ -58,7 +59,7 @@ class ActionService {
this._localAssetRepository, this._localAssetRepository,
this._albumApiRepository, this._albumApiRepository,
this._remoteAlbumRepository, this._remoteAlbumRepository,
this._trashedLocalAssetRepository, this._trashSyncRepository,
this._assetMediaRepository, this._assetMediaRepository,
this._downloadRepository, this._downloadRepository,
this._tagService, this._tagService,
@@ -298,10 +299,16 @@ class ActionService {
return 0; return 0;
} }
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds); await _trashSyncRepository.recordUserManualTrash(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
} }
await _localAssetRepository.delete(deletedIds);
return deletedIds.length; 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,6 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> { enum AppSettingsEnum<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false), manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
reviewOutOfSyncChangesAndroid<bool>(StoreKey.reviewOutOfSyncChangesAndroid, null, false),
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true), enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
@@ -23,4 +24,11 @@ class AppSettingsService {
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) { Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, 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;
}
}
} }
@@ -1,66 +0,0 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
class LocalFilesManagerService {
const LocalFilesManagerService();
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash');
Future<bool> moveToTrash(List<String> mediaUrls) async {
try {
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
} catch (e, s) {
_logger.warning('Error moving file to trash', e, s);
return false;
}
}
Future<bool> restoreFromTrash(String fileName, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash', e, s);
return false;
}
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<bool> requestManageMediaPermission() async {
try {
return await _channel.invokeMethod('requestManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission', e, s);
return false;
}
}
Future<bool> hasManageMediaPermission() async {
try {
return await _channel.invokeMethod('hasManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission state', e, s);
return false;
}
}
Future<bool> manageMediaPermission() async {
try {
return await _channel.invokeMethod('manageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission settings', e, s);
return false;
}
}
}
+3 -1
View File
@@ -12,11 +12,13 @@ class ImmichTheme {
ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale}) { ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale}) {
final isDark = colorScheme.brightness == Brightness.dark; final isDark = colorScheme.brightness == Brightness.dark;
final warningColor = isDark ? const Color(0xFFF3BC6A) : const Color(0xFFC47A00);
final onWarningColor = isDark ? Colors.black : Colors.white;
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: colorScheme.brightness, brightness: colorScheme.brightness,
colorScheme: colorScheme, colorScheme: colorScheme.copyWith(tertiary: warningColor, onTertiary: onWarningColor),
primaryColor: colorScheme.primary, primaryColor: colorScheme.primary,
hintColor: colorScheme.onSurfaceSecondary, hintColor: colorScheme.onSurfaceSecondary,
focusColor: colorScheme.primary, focusColor: colorScheme.primary,
+17 -7
View File
@@ -47,6 +47,7 @@ class ActionButtonContext {
final bool isCasting; final bool isCasting;
final TimelineOrigin timelineOrigin; final TimelineOrigin timelineOrigin;
final int selectedCount; final int selectedCount;
final bool isWaitingForTrashApproval;
const ActionButtonContext({ const ActionButtonContext({
required this.asset, required this.asset,
@@ -61,6 +62,7 @@ class ActionButtonContext {
this.isCasting = false, this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main, this.timelineOrigin = TimelineOrigin.main,
this.selectedCount = 1, this.selectedCount = 1,
this.isWaitingForTrashApproval = false,
}); });
} }
@@ -102,7 +104,8 @@ enum ActionButtonType {
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasRemote && // context.asset.hasRemote && //
!context.isArchived, !context.isArchived &&
!context.isWaitingForTrashApproval,
ActionButtonType.unarchive => ActionButtonType.unarchive =>
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
@@ -117,31 +120,37 @@ enum ActionButtonType {
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasRemote && // context.asset.hasRemote && //
context.isTrashEnabled && // context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash, context.timelineOrigin != TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
ActionButtonType.restoreTrash => ActionButtonType.restoreTrash =>
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasRemote && // context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash, context.timelineOrigin == TimelineOrigin.trash &&
!context.isWaitingForTrashApproval,
ActionButtonType.deletePermanent => ActionButtonType.deletePermanent =>
context.isOwner && // context.isOwner && //
context.asset.hasRemote && // context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView), (!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView) &&
!context.isWaitingForTrashApproval,
ActionButtonType.delete => ActionButtonType.delete =>
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasRemote, context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.moveToLockFolder => ActionButtonType.moveToLockFolder =>
context.isOwner && // context.isOwner && //
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasRemote, context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.removeFromLockFolder => ActionButtonType.removeFromLockFolder =>
context.isOwner && // context.isOwner && //
context.isInLockedView && // context.isInLockedView && //
context.asset.hasRemote, context.asset.hasRemote,
ActionButtonType.deleteLocal => ActionButtonType.deleteLocal =>
!context.isInLockedView && // !context.isInLockedView && //
context.asset.hasLocal, context.asset.hasLocal &&
!context.isWaitingForTrashApproval,
ActionButtonType.upload => ActionButtonType.upload =>
!context.isInLockedView && // !context.isInLockedView && //
context.asset.storage == AssetState.local, context.asset.storage == AssetState.local,
@@ -179,6 +188,7 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.lockedFolder && context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive && context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum && context.timelineOrigin != TimelineOrigin.localAlbum &&
context.timelineOrigin != TimelineOrigin.syncTrash &&
context.isOwner, context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote, ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
ActionButtonType.slideshow => true, ActionButtonType.slideshow => true,
@@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.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/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/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -68,19 +70,24 @@ class ImmichAppBarDialog extends HookConsumerWidget {
); );
} }
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing}) { buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing, Color? btnColor}) {
return ListTile( return ListTile(
dense: true, dense: true,
visualDensity: VisualDensity.standard, visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30, right: 30), contentPadding: const EdgeInsets.only(left: 30, right: 30),
minLeadingWidth: 40, minLeadingWidth: 40,
leading: SizedBox(child: Icon(icon, color: theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20)), leading: SizedBox(
child: Icon(icon, color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20),
),
title: Text( title: Text(
text, text,
style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)), style: theme.textTheme.labelLarge?.copyWith(
color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250),
),
).tr(), ).tr(),
onTap: onTap, onTap: onTap,
trailing: trailing, trailing: trailing,
iconColor: btnColor,
); );
} }
@@ -96,6 +103,25 @@ 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() { buildAppLogButton() {
return buildActionButton( return buildActionButton(
Icons.assignment_outlined, Icons.assignment_outlined,
@@ -269,6 +295,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
], ],
), ),
), ),
buildOutOfSyncButton(),
if (isReadonlyModeEnabled) buildReadonlyMessage(), if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(), buildAppLogButton(),
buildFreeUpSpaceButton(), buildFreeUpSpaceButton(),
@@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.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/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -111,6 +112,7 @@ class _ProfileIndicator extends ConsumerWidget {
// TODO: remove this when update Flutter version newer than 3.35.7 // TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
void toggleReadonlyMode() { void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
@@ -147,7 +149,7 @@ class _ProfileIndicator extends ConsumerWidget {
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
isLabelVisible: versionWarningPresent, isLabelVisible: versionWarningPresent || outOfSyncCount > 0,
offset: const Offset(-2, -12), offset: const Offset(-2, -12),
child: user == null child: user == null
? const Icon(Icons.face_outlined, size: widgetSize) ? 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/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
} }
getManageMediaPermission() async { getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission(); final hasPermission = await ref.read(assetMediaRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) { if (!hasPermission) {
await showDialog( await showDialog(
context: context, context: context,
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); ref.read(assetMediaRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text( child: Text(
@@ -7,17 +7,20 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.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_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_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.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/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart'; import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -28,9 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false); final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index); final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote); final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged( useValueChanged(
@@ -56,11 +57,6 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() { useEffect(() {
() async { () async {
isManageMediaSupported.value = await checkAndroidVersion(); isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref
.read(localFilesManagerRepositoryProvider)
.hasManageMediaPermission();
}
}(); }();
return null; return null;
}, []); }, []);
@@ -72,36 +68,11 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(), title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(), subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
), ),
if (isManageMediaSupported.value) // Android 12+: full selector (Off / Auto sync / Review) + MANAGE_MEDIA tile.
Column( // iOS: reduced selector (Off / Review) — no MANAGE_MEDIA on this
children: [ // platform; auto-sync is dropped because PhotoKit prompts on
SettingsSwitchListTile( // every batch, which would defeat the "set and forget" intent.
enabled: true, if (isManageMediaSupported.value || Platform.isIOS) const _TrashSyncModeSelector(),
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( SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}), text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
valueNotifier: levelId, valueNotifier: levelId,
@@ -178,3 +149,135 @@ class AdvancedSettings extends HookConsumerWidget {
return SettingsSubPageScaffold(settings: advancedSettings); 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,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -354,8 +355,10 @@ class _SyncStatsCounts extends ConsumerWidget {
), ),
), ),
// To be removed once the experimental feature is stable // To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid && if ((kDebugMode || kProfileMode) &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[ CurrentPlatform.isAndroid &&
(appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid) ||
appSettingsService.getSetting<bool>(AppSettingsEnum.reviewOutOfSyncChangesAndroid))) ...[
SettingGroupTitle(title: "trash".t(context: context)), SettingGroupTitle(title: "trash".t(context: context)),
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class SettingsRadioGroup<T> { class SettingsRadioGroup<T> {
final String title; final String title;
final String? subtitle;
final T value; final T value;
const SettingsRadioGroup({required this.title, required this.value}); const SettingsRadioGroup({required this.title, this.subtitle, required this.value});
} }
class SettingsRadioListTile<T> extends StatelessWidget { class SettingsRadioListTile<T> extends StatelessWidget {
@@ -28,6 +30,12 @@ class SettingsRadioListTile<T> extends StatelessWidget {
dense: true, dense: true,
activeColor: context.primaryColor, activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), 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, value: g.value,
controlAffinity: ListTileControlAffinity.trailing, controlAffinity: ListTileControlAffinity.trailing,
), ),
+12 -8
View File
@@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile', dartPackageName: 'immich_mobile',
), ),
) )
enum PlatformAssetPlaybackStyle { enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset { class PlatformAsset {
final String id; final String id;
@@ -142,6 +135,17 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets(); Map<String, List<PlatformAsset>> getTrashedAssets();
bool hasManageMediaPermission();
@async
bool requestManageMediaPermission();
@async
bool manageMediaPermission();
@async
bool restoreFromTrashById(String mediaId, int type);
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds); List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
} }
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart'; import 'package:immich_mobile/domain/services/local_sync.service.dart';
@@ -10,32 +11,32 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart'; import '../../domain/service.mock.dart';
import '../../fixtures/asset.stub.dart'; import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
void main() { void main() {
late LocalSyncService sut; late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository; late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository; late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late LocalFilesManagerRepository mockLocalFilesManager; late MockDriftTrashSyncRepository mockDriftTrashSyncRepository;
late StorageRepository mockStorageRepository;
late MockNativeSyncApi mockNativeSyncApi; late MockNativeSyncApi mockNativeSyncApi;
late Drift db; late Drift db;
setUpAll(() async { setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android; debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<LocalAsset>[]);
registerFallbackValue(<LocalAlbum>[]);
registerFallbackValue(<String>[]);
registerFallbackValue(<String, List<String>>{});
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db)); await StoreService.init(storeRepository: DriftStoreRepository(db));
@@ -48,11 +49,12 @@ void main() {
}); });
setUp(() async { setUp(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
mockLocalAlbumRepository = MockLocalAlbumRepository(); mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository(); mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository(); mockDriftTrashSyncRepository = MockDriftTrashSyncRepository();
mockStorageRepository = MockStorageRepository();
mockNativeSyncApi = MockNativeSyncApi(); mockNativeSyncApi = MockNativeSyncApi();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false); when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
@@ -60,70 +62,58 @@ void main() {
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}), (_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
); );
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {}); when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
when(() => mockNativeSyncApi.checkpointSync()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {}); when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []); when(() => mockDriftTrashSyncRepository.cleanup()).thenAnswer((_) async => 0);
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {}); when(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {}); when(() => mockDriftTrashSyncRepository.recheckRemoteTrashCandidates()).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
sut = LocalSyncService( sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository, localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository, localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository, trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
localFilesManager: mockLocalFilesManager, trashSyncRepository: mockDriftTrashSyncRepository,
storageRepository: mockStorageRepository,
nativeSyncApi: mockNativeSyncApi, nativeSyncApi: mockNativeSyncApi,
); );
await Store.clear();
await Store.put(StoreKey.manageLocalMediaAndroid, false); await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false); await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
}); });
group('LocalSyncService - syncTrashedAssets gating', () { // After the refactor, LocalSyncService is just the OS-trash mirror
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async { // updater plus a delegating cleanup hook. The restore branch that
await Store.put(StoreKey.manageLocalMediaAndroid, true); // used to live here is now owned by DriftTrashSyncRepository (tested in
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); // 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);
await sut.sync(); await sut.sync();
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1); verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1); verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
}); });
test('skips syncTrashedAssets when store flag disabled', () async { test('invokes catch-up restore on Android', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false); // Regression: my refactor initially dropped this catch-up. The
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true); // original PR ran restore detection in `processTrashedAssets`
// every sync. We preserve that periodic check via
// DriftTrashSyncRepository.syncRestoresForRevivedAssets.
await sut.sync(); await sut.sync();
verify(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).called(1);
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
}); });
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async { test('skips mirror and catch-up on non-Android platforms', () 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; debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync(); await sut.sync();
verifyNever(() => mockNativeSyncApi.getTrashedAssets()); verifyNever(() => mockNativeSyncApi.getTrashedAssets());
verifyNever(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets());
}); });
});
group('LocalSyncService - syncTrashedAssets behavior', () { test('processTrashedAssets writes the OS mirror and no longer calls restore', () async {
test('processes trashed snapshot, restores assets, and trashes local files', () async {
final platformAsset = PlatformAsset( final platformAsset = PlatformAsset(
id: 'remote-id', id: 'remote-id',
name: 'remote.jpg', name: 'remote.jpg',
@@ -131,29 +121,9 @@ void main() {
durationMs: 0, durationMs: 0,
orientation: 0, orientation: 0,
isFavorite: false, 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({ await sut.processTrashedAssets({
'album-a': [platformAsset], 'album-a': [platformAsset],
}); });
@@ -166,41 +136,47 @@ void main() {
expect(trashedEntry.albumId, 'album-a'); expect(trashedEntry.albumId, 'album-a');
expect(trashedEntry.asset.id, platformAsset.id); expect(trashedEntry.asset.id, platformAsset.id);
expect(trashedEntry.asset.name, platformAsset.name); 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('does not attempt restore when repository has no assets to restore', () async { test('processTrashedAssets handles empty snapshot without errors', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
await sut.processTrashedAssets({}); await sut.processTrashedAssets({});
final trashedSnapshot = final trashedSnapshot =
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
as Iterable<TrashedAsset>; as Iterable<TrashedAsset>;
expect(trashedSnapshot, isEmpty); expect(trashedSnapshot, isEmpty);
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any())); });
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())); });
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);
}); });
test('does not move local assets when repository finds nothing to trash', () async { test('cleans trash state after Android delta sync with changes', () async {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) 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 => []);
await sut.processTrashedAssets({}); await sut.sync();
verifyNever(() => mockLocalFilesManager.moveToTrash(any())); verify(() => mockDriftTrashSyncRepository.cleanup()).called(1);
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
}); });
}); });
@@ -215,7 +191,7 @@ void main() {
isFavorite: false, isFavorite: false,
createdAt: 1700000000, createdAt: 1700000000,
updatedAt: 1732000000, updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image playbackStyle: PlatformAssetPlaybackStyle.image,
); );
final localAsset = platformAsset.toLocalAsset(); final localAsset = platformAsset.toLocalAsset();
@@ -4,20 +4,16 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.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/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.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/store.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -26,8 +22,6 @@ import '../../api.mocks.dart';
import '../../fixtures/asset.stub.dart'; import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart'; import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart'; import '../../service.mocks.dart';
class _AbortCallbackWrapper { class _AbortCallbackWrapper {
@@ -50,10 +44,7 @@ void main() {
late SyncStreamService sut; late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo; late SyncStreamRepository mockSyncStreamRepo;
late SyncApiRepository mockSyncApiRepo; late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo; late DriftTrashSyncRepository mockDriftTrashSyncRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late MockApiService mockApi; late MockApiService mockApi;
late MockServerApi mockServerApi; late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo; late MockSyncMigrationRepository mockSyncMigrationRepo;
@@ -61,7 +52,6 @@ void main() {
late _MockAbortCallbackWrapper mockAbortCallbackWrapper; late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper; late _MockAbortCallbackWrapper mockResetCallbackWrapper;
late Drift db; late Drift db;
late bool hasManageMediaPermission;
setUpAll(() async { setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -70,7 +60,7 @@ void main() {
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0)); registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db)); await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
}); });
tearDownAll(() async { tearDownAll(() async {
@@ -84,10 +74,7 @@ void main() {
setUp(() async { setUp(() async {
mockSyncStreamRepo = MockSyncStreamRepository(); mockSyncStreamRepo = MockSyncStreamRepository();
mockSyncApiRepo = MockSyncApiRepository(); mockSyncApiRepo = MockSyncApiRepository();
mockLocalAssetRepo = MockLocalAssetRepository(); mockDriftTrashSyncRepository = MockDriftTrashSyncRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper(); mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper(); mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService(); mockApi = MockApiService();
@@ -157,24 +144,15 @@ void main() {
sut = SyncStreamService( sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo, syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo, syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo, trashSyncRepository: mockDriftTrashSyncRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
api: mockApi, api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo, syncMigrationRepository: mockSyncMigrationRepo,
); );
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {}); when(() => mockDriftTrashSyncRepository.recordRemoteTrash(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {}); when(() => mockDriftTrashSyncRepository.recordRemoteRestore(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.manageLocalMediaAndroid, false);
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
}); });
Future<void> simulateEvents(List<SyncEvent> events) async { Future<void> simulateEvents(List<SyncEvent> events) async {
@@ -239,10 +217,7 @@ void main() {
sut = SyncStreamService( sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo, syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo, syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo, trashSyncRepository: mockDriftTrashSyncRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call, cancelChecker: cancellationChecker.call,
api: mockApi, api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo, syncMigrationRepository: mockSyncMigrationRepo,
@@ -280,10 +255,7 @@ void main() {
sut = SyncStreamService( sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo, syncApiRepository: mockSyncApiRepo,
syncStreamRepository: mockSyncStreamRepo, syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo, trashSyncRepository: mockDriftTrashSyncRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call, cancelChecker: cancellationChecker.call,
api: mockApi, api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo, syncMigrationRepository: mockSyncMigrationRepo,
@@ -396,128 +368,68 @@ void main() {
}); });
}); });
group("SyncStreamService - remote trash & restore", () { group("SyncStreamService - delegates trash events to DriftTrashSyncRepository", () {
setUp(() async { test("assetV1 with deletedAt routes trash intents through recordRemoteTrash", () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true); final trashedAt = DateTime(2025, 5, 1);
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 = [ final events = [
SyncStreamStub.assetTrashed( SyncStreamStub.assetTrashed(
id: 'remote-1', id: 'remote-1',
checksum: localAsset.checksum!, checksum: 'checksum-1',
ack: 'asset-remote-local-1', ack: 'asset-trashed-1',
trashedAt: DateTime(2025, 5, 1), trashedAt: trashedAt,
),
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); await simulateEvents(events);
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1); final captured =
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1); verify(() => mockDriftTrashSyncRepository.recordRemoteTrash(captureAny())).captured.single
as Map<String, DateTime>;
expect(captured, {'remote-1': trashedAt});
}); });
test("skips device trashing when no local assets match the remote trash payload", () async { test("assetV1 with null deletedAt routes alive checksums through recordRemoteRestore", () async {
final events = [ final events = [SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-1', ack: 'asset-restored-1')];
SyncStreamStub.assetTrashed(
id: 'remote-only',
checksum: 'checksum-only',
ack: 'asset-remote-only-9',
trashedAt: DateTime(2025, 6, 1),
),
];
await simulateEvents(events); await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); final captured =
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); verify(() => mockDriftTrashSyncRepository.recordRemoteRestore(captureAny())).captured.single
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())); as Iterable<String>;
expect(captured.toList(), ['checksum-1']);
}); });
test("requests local deletions lookup by remote ids for permanent remote delete events", () async { test("assetDeleteV1 events route through recordRemoteTrash (permanent delete)", () 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]; final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events); await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); verify(() => mockDriftTrashSyncRepository.recordRemoteTrash(any())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
}); });
test("restores trashed local assets once the matching remote assets leave the trash", () async { test("mixed batch routes trash and restore in one call each", () async {
final trashedAssets = [ final trashedAt = DateTime(2025, 5, 1);
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 = [ final events = [
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'), SyncStreamStub.assetTrashed(
id: 'remote-trashed',
checksum: 'checksum-trashed',
ack: 'asset-trashed',
trashedAt: trashedAt,
),
SyncStreamStub.assetModified(id: 'remote-alive', checksum: 'checksum-alive', ack: 'asset-alive'),
]; ];
await simulateEvents(events); await simulateEvents(events);
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1); 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']);
}); });
}); });
+4
View File
@@ -30,6 +30,7 @@ import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24; import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25; import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26; import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -87,6 +88,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v25.DatabaseAtV25(db); return v25.DatabaseAtV25(db);
case 26: case 26:
return v26.DatabaseAtV26(db); return v26.DatabaseAtV26(db);
case 27:
return v27.DatabaseAtV27(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@@ -119,5 +122,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
24, 24,
25, 25,
26, 26,
27,
]; ];
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
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));
}
Future<void> insertTrashSync({required String localAssetId, String? checksum}) {
final now = DateTime(2025, 1, 10, 12);
return db
.into(db.trashSyncEntity)
.insert(
TrashSyncEntityCompanion.insert(
id: 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']);
});
});
}
@@ -0,0 +1,358 @@
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/remote_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/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));
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();
});
TrashSyncCandidate candidate({
required String localAssetId,
String? checksum,
DateTime? remoteDeletedAt,
TrashTriggerSource triggerSource = TrashTriggerSource.remoteSync,
}) {
final now = DateTime(2025, 1, 1);
return TrashSyncCandidate(
localAssetId: localAssetId,
checksum: checksum,
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,
DateTime? remoteDeletedAt,
}) async {
final now = DateTime(2025, 1, 1);
await db
.into(db.trashSyncEntity)
.insert(
TrashSyncEntityCompanion.insert(
id: localAssetId,
checksum: Value(checksum),
decision: decision,
triggerSource: triggerSource,
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),
),
);
}
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);
});
});
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.id: 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);
});
});
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));
});
});
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.id).toSet(), {'a', 'c'});
final remaining = await db.select(db.trashSyncEntity).get();
expect(remaining.map((r) => r.id).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);
});
});
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.id).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.id).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.id).toSet(), {'survivor'});
});
});
}
@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {} class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockDriftTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
class MockStorageRepository extends Mock implements StorageRepository {} class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {} class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
@@ -1,6 +1,10 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.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/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/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/utils/option.dart'; import 'package:immich_mobile/utils/option.dart';
@@ -19,6 +23,85 @@ void main() {
await ctx.dispose(); await ctx.dispose();
}); });
group('getRemoteTrashCandidates', () {
test('returns local assets from selected backup albums matched by remote id (deduped across albums)', () 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 secondSelected = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final unselectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
// Same local asset in two selected albums + one unselected. Should
// collapse to a single candidate (decision is per-file).
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: localAsset.id);
await ctx.newLocalAlbumAsset(albumId: secondSelected.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.getRemoteTrashCandidates({
remoteAsset.id: remoteDeletedAt,
remoteOnlyAsset.id: DateTime(2025, 6, 2),
});
expect(result, hasLength(1));
expect(result.single.asset.id, localAsset.id);
expect(result.single.asset.remoteId, remoteAsset.id);
expect(result.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);
Future<void> seed(String localAssetId, String checksum, TrashStateDecision decision) async {
await ctx.db
.into(ctx.db.trashSyncEntity)
.insert(
TrashSyncEntityCompanion.insert(
id: 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.getRemoteTrashCandidates({
pendingRemote.id: remoteDeletedAt,
keptRemote.id: remoteDeletedAt,
trashedRemote.id: remoteDeletedAt,
});
expect(result.map((item) => item.asset.id), [pendingLocal.id]);
});
});
group('getRemovalCandidates', () { group('getRemovalCandidates', () {
final cutoffDate = DateTime(2024, 1, 1); final cutoffDate = DateTime(2024, 1, 1);
final beforeCutoff = DateTime(2023, 12, 31); final beforeCutoff = DateTime(2023, 12, 31);
+1 -4
View File
@@ -1,9 +1,8 @@
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.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.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart'; import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
class MockAssetApiRepository extends Mock implements AssetApiRepository {} class MockAssetApiRepository extends Mock implements AssetApiRepository {}
@@ -14,6 +13,4 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {}
class MockAuthRepository extends Mock implements AuthRepository {} class MockAuthRepository extends Mock implements AuthRepository {}
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
class MockTagService extends Mock implements TagService {} class MockTagService extends Mock implements TagService {}
+54 -11
View File
@@ -11,6 +11,7 @@ import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/action.service.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart'; import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart'; import '../repository.mocks.dart';
@@ -24,7 +25,7 @@ void main() {
late MockDriftLocalAssetRepository localAssetRepository; late MockDriftLocalAssetRepository localAssetRepository;
late MockDriftAlbumApiRepository albumApiRepository; late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository; late MockRemoteAlbumRepository remoteAlbumRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository; late MockDriftTrashSyncRepository trashSyncRepository;
late MockAssetMediaRepository assetMediaRepository; late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository; late MockDownloadRepository downloadRepository;
late MockTagService tagService; late MockTagService tagService;
@@ -32,6 +33,7 @@ void main() {
late Drift db; late Drift db;
setUpAll(() async { setUpAll(() async {
registerFallbackValue(LocalAssetStub.image1);
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android; debugDefaultTargetPlatformOverride = TargetPlatform.android;
@@ -51,7 +53,7 @@ void main() {
localAssetRepository = MockDriftLocalAssetRepository(); localAssetRepository = MockDriftLocalAssetRepository();
albumApiRepository = MockDriftAlbumApiRepository(); albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository(); remoteAlbumRepository = MockRemoteAlbumRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository(); trashSyncRepository = MockDriftTrashSyncRepository();
assetMediaRepository = MockAssetMediaRepository(); assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository(); downloadRepository = MockDownloadRepository();
tagService = MockTagService(); tagService = MockTagService();
@@ -62,11 +64,14 @@ void main() {
localAssetRepository, localAssetRepository,
albumApiRepository, albumApiRepository,
remoteAlbumRepository, remoteAlbumRepository,
trashedLocalAssetRepository, trashSyncRepository,
assetMediaRepository, assetMediaRepository,
downloadRepository, downloadRepository,
tagService, tagService,
); );
when(() => trashSyncRepository.recordUserManualTrash(any())).thenAnswer((_) async {});
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
}); });
tearDown(() async { tearDown(() async {
@@ -74,34 +79,32 @@ void main() {
}); });
group('ActionService.deleteLocal', () { group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async { test('records user manual trash and deletes local asset row when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true); await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b']; const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids); final result = await sut.deleteLocal(ids);
expect(result, ids.length); expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1); verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1); verify(() => trashSyncRepository.recordUserManualTrash(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any())); verify(() => localAssetRepository.delete(ids)).called(1);
}); });
test('deletes locally when Android trash handling is disabled', () async { test('only deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false); await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c']; const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids); when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids); final result = await sut.deleteLocal(ids);
expect(result, ids.length); expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1); verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1); verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); verifyNever(() => trashSyncRepository.recordUserManualTrash(any()));
}); });
test('short-circuits when nothing was deleted', () async { test('short-circuits when nothing was deleted', () async {
@@ -114,8 +117,48 @@ void main() {
expect(result, 0); expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids)).called(1); verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any())); verifyNever(() => trashSyncRepository.recordUserManualTrash(any()));
verifyNever(() => localAssetRepository.delete(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,6 +91,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -122,7 +123,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.share.shouldShow(context), isTrue); expect(ActionButtonType.share.shouldShow(context), isTrue);
@@ -138,7 +140,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.share.shouldShow(context), isTrue); expect(ActionButtonType.share.shouldShow(context), isTrue);
@@ -157,7 +160,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.shareLink.shouldShow(context), isTrue); expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
@@ -174,7 +178,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.shareLink.shouldShow(context), isFalse); expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@@ -191,7 +196,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.shareLink.shouldShow(context), isFalse); expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@@ -210,7 +216,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.archive.shouldShow(context), isTrue); expect(ActionButtonType.archive.shouldShow(context), isTrue);
@@ -227,7 +234,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.archive.shouldShow(context), isFalse); expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -244,7 +252,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.archive.shouldShow(context), isFalse); expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -261,7 +270,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.archive.shouldShow(context), isFalse); expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -278,7 +288,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.archive.shouldShow(context), isFalse); expect(ActionButtonType.archive.shouldShow(context), isFalse);
@@ -297,7 +308,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.unarchive.shouldShow(context), isTrue); expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
@@ -314,7 +326,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.unarchive.shouldShow(context), isFalse); expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@@ -331,7 +344,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.unarchive.shouldShow(context), isFalse); expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@@ -350,7 +364,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.download.shouldShow(context), isTrue); expect(ActionButtonType.download.shouldShow(context), isTrue);
@@ -367,7 +382,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.download.shouldShow(context), isFalse); expect(ActionButtonType.download.shouldShow(context), isFalse);
@@ -384,7 +400,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.download.shouldShow(context), isFalse); expect(ActionButtonType.download.shouldShow(context), isFalse);
@@ -403,7 +420,8 @@ void main() {
isStacked: false, isStacked: false,
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue); expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue);
@@ -420,7 +438,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
isStacked: false, isStacked: false,
advancedTroubleshooting: false, advancedTroubleshooting: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse); expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse);
@@ -439,7 +458,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.trash.shouldShow(context), isTrue); expect(ActionButtonType.trash.shouldShow(context), isTrue);
@@ -456,7 +476,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.trash.shouldShow(context), isFalse); expect(ActionButtonType.trash.shouldShow(context), isFalse);
@@ -531,7 +552,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue); expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
@@ -548,7 +570,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
@@ -585,7 +608,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.delete.shouldShow(context), isTrue); expect(ActionButtonType.delete.shouldShow(context), isTrue);
@@ -604,7 +628,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue); expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
@@ -623,7 +648,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue); expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@@ -640,7 +666,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse); expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
@@ -656,7 +683,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue); expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@@ -675,7 +703,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.upload.shouldShow(context), isTrue); expect(ActionButtonType.upload.shouldShow(context), isTrue);
@@ -694,7 +723,8 @@ void main() {
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue); expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
@@ -710,7 +740,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse); expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
@@ -908,7 +939,8 @@ void main() {
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue); expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
@@ -925,7 +957,8 @@ void main() {
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -942,7 +975,8 @@ void main() {
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -958,7 +992,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@@ -976,7 +1011,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: true, advancedTroubleshooting: true,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue); expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue);
@@ -992,7 +1028,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse); expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse);
@@ -1012,6 +1049,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: true, isStacked: true,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -1029,6 +1067,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -1046,6 +1085,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -1068,6 +1108,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
}); });
@@ -1087,7 +1128,8 @@ void main() {
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>()); expect(widget, isA<Widget>());
@@ -1101,7 +1143,8 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>()); expect(widget, isA<Widget>());
@@ -1131,7 +1174,8 @@ void main() {
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: true, isStacked: true,
source: ActionSource.timeline, isWaitingForTrashApproval: false,
source: ActionSource.timeline,
); );
final widget = buttonType.buildButton(contextWithAlbum); final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>()); expect(widget, isA<Widget>());
@@ -1155,6 +1199,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -1176,6 +1221,7 @@ void main() {
currentAlbum: album, currentAlbum: album,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -1195,6 +1241,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -1215,6 +1262,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );
@@ -1229,6 +1277,7 @@ void main() {
currentAlbum: null, currentAlbum: null,
advancedTroubleshooting: false, advancedTroubleshooting: false,
isStacked: false, isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline, source: ActionSource.timeline,
); );