mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 23:26:31 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 653c4db355 | |||
| 915d865ce2 | |||
| c28e5f90b6 | |||
| 4383473ed6 | |||
| 77701dd5a3 |
@@ -159,14 +159,14 @@ jobs:
|
||||
|
||||
- name: Comment APK download link on PR
|
||||
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
|
||||
uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'mobile-android-apk'
|
||||
message: |
|
||||
id: mobile-android-apk
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: |
|
||||
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
|
||||
|
||||
Download: ${{ env.APK_URL }}
|
||||
|
||||
@@ -213,12 +213,11 @@ jobs:
|
||||
run: 'mise run //deployment:tf apply'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
if: ${{ steps.parameters.outputs.event == 'pr' }}
|
||||
with:
|
||||
id: docs-pr-url
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
|
||||
body: |
|
||||
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
|
||||
emojis: 'rocket'
|
||||
body-include: '<!-- Docs PR URL -->'
|
||||
|
||||
@@ -44,9 +44,8 @@ jobs:
|
||||
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||
|
||||
- name: Comment
|
||||
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
|
||||
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
with:
|
||||
id: docs-pr-url
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
number: ${{ github.event.number }}
|
||||
delete: true
|
||||
body-include: '<!-- Docs PR URL -->'
|
||||
|
||||
@@ -19,11 +19,11 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
|
||||
id: preview-status
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
|
||||
|
||||
remove-label:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,16 +48,16 @@ jobs:
|
||||
name: 'preview'
|
||||
})
|
||||
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'PRs from forks cannot have preview environments.'
|
||||
id: preview-status
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: 'PRs from forks cannot have preview environments.'
|
||||
|
||||
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
|
||||
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'Preview environment has been removed.'
|
||||
id: preview-status
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
body: 'Preview environment has been removed.'
|
||||
|
||||
+15
-7
@@ -465,10 +465,14 @@
|
||||
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
|
||||
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
|
||||
"advanced_settings_readonly_mode_title": "Read-only mode",
|
||||
"advanced_settings_review_remote_deletions_subtitle": "Manually review cloud trash changes. Restorations are applied automatically.",
|
||||
"advanced_settings_review_remote_deletions_title": "Review remote deletions",
|
||||
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
|
||||
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
|
||||
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
|
||||
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
|
||||
"advanced_settings_sync_remote_deletions_off_subtitle": "Cloud trash changes are ignored",
|
||||
"advanced_settings_sync_remote_deletions_selector_title": "Sync remote deletions [EXPERIMENTAL]",
|
||||
"advanced_settings_sync_remote_deletions_subtitle": "Automatically move assets to trash or restore them on this device when that action is taken on the web.",
|
||||
"advanced_settings_sync_remote_deletions_title": "Auto sync",
|
||||
"advanced_settings_tile_subtitle": "Advanced user's settings",
|
||||
"advanced_settings_troubleshooting_subtitle": "Enable additional features for 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_offline": "Asset Offline",
|
||||
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
|
||||
"asset_out_of_sync_title": "Out-of-sync assets list",
|
||||
"asset_out_of_sync_trash_confirmation_text": "Move {count, plural, one {asset} other {# assets}} to your device trash?",
|
||||
"asset_out_of_sync_trash_confirmation_title": "Sync trash change",
|
||||
"asset_out_of_sync_trash_subtitle": "Assets moved to the Immich cloud trash: choose to move them to local trash or keep them on this device.",
|
||||
"asset_out_of_sync_trash_subtitle_result": "Nothing left to review — all decisions applied.",
|
||||
"asset_restored_successfully": "Asset restored successfully",
|
||||
"asset_skipped": "Skipped",
|
||||
"asset_skipped_in_trash": "In trash",
|
||||
@@ -597,6 +606,7 @@
|
||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
||||
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
||||
"assets_denied_to_moved_to_trash_count": "Keeping local copies of {count, plural, one {# asset} other {# assets}}",
|
||||
"assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
|
||||
"assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
|
||||
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
|
||||
@@ -976,7 +986,6 @@
|
||||
"downloading_asset_filename": "Downloading asset {filename}",
|
||||
"downloading_from_icloud": "Downloading from iCloud",
|
||||
"downloading_media": "Downloading media",
|
||||
"drag_to_reorder": "Drag to reorder",
|
||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||
"duplicates": "Duplicates",
|
||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
||||
@@ -1367,6 +1376,7 @@
|
||||
"keep_all": "Keep All",
|
||||
"keep_description": "Choose what stays on your device when freeing up space.",
|
||||
"keep_favorites": "Keep favorites",
|
||||
"keep_in_trash": "Keep in trash",
|
||||
"keep_on_device": "Keep on device",
|
||||
"keep_on_device_hint": "Select items to keep on this device",
|
||||
"keep_this_delete_others": "Keep this, delete others",
|
||||
@@ -1686,6 +1696,7 @@
|
||||
"obtainium_configurator": "Obtainium Configurator",
|
||||
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
|
||||
"ocr": "OCR",
|
||||
"off": "Off",
|
||||
"official_immich_resources": "Official Immich Resources",
|
||||
"offline": "Offline",
|
||||
"offset": "Offset",
|
||||
@@ -1960,6 +1971,7 @@
|
||||
"retry_upload": "Retry upload",
|
||||
"review_duplicates": "Review duplicates",
|
||||
"review_large_files": "Review large files",
|
||||
"review_out_of_sync_changes": "Review out-of-sync changes",
|
||||
"role": "Role",
|
||||
"role_editor": "Editor",
|
||||
"role_viewer": "Viewer",
|
||||
@@ -2255,7 +2267,6 @@
|
||||
"step_delete_confirm": "Are you sure you want to delete this step?",
|
||||
"step_details": "Step details",
|
||||
"steps": "Steps",
|
||||
"steps_count": "{count, plural, one {# step} other {# steps}}",
|
||||
"stop_casting": "Stop casting",
|
||||
"stop_motion_photo": "Stop Motion Photo",
|
||||
"stop_photo_sharing": "Stop sharing your photos?",
|
||||
@@ -2417,7 +2428,6 @@
|
||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||
"use_current_connection": "Use current connection",
|
||||
"use_custom_date_range": "Use custom date range instead",
|
||||
"use_template": "Use template",
|
||||
"user": "User",
|
||||
"user_has_been_deleted": "This user has been deleted.",
|
||||
"user_id": "User ID",
|
||||
@@ -2479,7 +2489,6 @@
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"when": "When",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow": "Workflow",
|
||||
@@ -2492,7 +2501,6 @@
|
||||
"workflow_name": "Workflow name",
|
||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||
"workflow_summary": "Workflow summary",
|
||||
"workflow_templates": "Workflow templates",
|
||||
"workflow_update_success": "Workflow updated successfully",
|
||||
"workflow_updated": "Workflow updated",
|
||||
"workflows": "Workflows",
|
||||
|
||||
@@ -89,6 +89,13 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
constraints {
|
||||
implementation("androidx.glance:glance-appwidget") {
|
||||
version { strictly libs.versions.glance.get() }
|
||||
because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
|
||||
}
|
||||
}
|
||||
|
||||
implementation libs.okhttp
|
||||
implementation libs.cronet.embedded
|
||||
implementation libs.media3.datasource.okhttp
|
||||
|
||||
@@ -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 cancelHashing()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun hasManageMediaPermission(): Boolean
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
|
||||
companion object {
|
||||
@@ -747,6 +751,78 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hasManageMediaPermission())
|
||||
} catch (exception: Throwable) {
|
||||
MessagesPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.requestManageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.manageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val mediaIdArg = args[0] as String
|
||||
val typeArg = args[1] as Long
|
||||
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -39,10 +41,11 @@ sealed class AssetResult {
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||
@@ -448,6 +451,36 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
hashTask = null
|
||||
}
|
||||
|
||||
fun hasManageMediaPermission(): Boolean = mediaTrashDelegate.hasManageMediaPermission()
|
||||
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.manageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
|
||||
+3701
File diff suppressed because it is too large
Load Diff
Generated
+69
@@ -537,6 +537,10 @@ protocol NativeSyncApi {
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func hasManageMediaPermission() throws -> Bool
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
}
|
||||
|
||||
@@ -721,6 +725,71 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.hasManageMediaPermission()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
api.requestManageMediaPermission { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
manageMediaPermissionChannel.setMessageHandler {
|
||||
_, reply in
|
||||
api.manageMediaPermission {
|
||||
result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
manageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
restoreFromTrashByIdChannel.setMessageHandler {
|
||||
message, reply in
|
||||
let args = message as! [Any?]
|
||||
let mediaIdArg = args[0] as !String
|
||||
let typeArg = args[1] as !Int64
|
||||
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) {
|
||||
result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreFromTrashByIdChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
|
||||
@@ -382,6 +382,22 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
func hasManageMediaPermission() throws -> Bool {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
|
||||
}
|
||||
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
|
||||
}
|
||||
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.failure(PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)))
|
||||
}
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
|
||||
@@ -31,7 +31,7 @@ class LocalAsset extends BaseAsset {
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
super.isEdited = false,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
|
||||
@@ -23,6 +23,7 @@ class RemoteAsset extends BaseAsset {
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
this.uploadedAt,
|
||||
this.deletedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationMs,
|
||||
@@ -32,7 +33,6 @@ class RemoteAsset extends BaseAsset {
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -62,6 +62,7 @@ class RemoteAsset extends BaseAsset {
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
uploadedAt: ${uploadedAt ?? "<NA>"},
|
||||
deletedAt: ${deletedAt ?? "<NA>"},
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationMs: ${durationMs ?? "<NA>"},
|
||||
@@ -89,6 +90,7 @@ class RemoteAsset extends BaseAsset {
|
||||
ownerId == other.ownerId &&
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
deletedAt == other.deletedAt &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
@@ -102,6 +104,7 @@ class RemoteAsset extends BaseAsset {
|
||||
localId.hashCode ^
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
deletedAt.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
@@ -116,6 +119,7 @@ class RemoteAsset extends BaseAsset {
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
DateTime? deletedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationMs,
|
||||
@@ -125,7 +129,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -137,6 +140,7 @@ class RemoteAsset extends BaseAsset {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
uploadedAt: uploadedAt ?? this.uploadedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
@@ -146,7 +150,6 @@ class RemoteAsset extends BaseAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
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});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/backup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
@@ -16,6 +17,7 @@ class AppConfig {
|
||||
final ViewerConfig viewer;
|
||||
final SlideshowConfig slideshow;
|
||||
final AlbumConfig album;
|
||||
final BackupConfig backup;
|
||||
|
||||
const AppConfig({
|
||||
this.theme = const .new(),
|
||||
@@ -26,6 +28,7 @@ class AppConfig {
|
||||
this.viewer = const .new(),
|
||||
this.slideshow = const .new(),
|
||||
this.album = const .new(),
|
||||
this.backup = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -37,6 +40,7 @@ class AppConfig {
|
||||
ViewerConfig? viewer,
|
||||
SlideshowConfig? slideshow,
|
||||
AlbumConfig? album,
|
||||
BackupConfig? backup,
|
||||
}) => .new(
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
@@ -46,6 +50,7 @@ class AppConfig {
|
||||
viewer: viewer ?? this.viewer,
|
||||
slideshow: slideshow ?? this.slideshow,
|
||||
album: album ?? this.album,
|
||||
backup: backup ?? this.backup,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -59,12 +64,13 @@ class AppConfig {
|
||||
other.image == image &&
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album);
|
||||
other.album == album &&
|
||||
other.backup == backup);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album);
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album)';
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
class BackupConfig {
|
||||
final bool enabled;
|
||||
final bool useCellularForVideos;
|
||||
final bool useCellularForPhotos;
|
||||
final bool requireCharging;
|
||||
final int triggerDelay;
|
||||
final bool syncAlbums;
|
||||
|
||||
const BackupConfig({
|
||||
this.enabled = false,
|
||||
this.useCellularForVideos = false,
|
||||
this.useCellularForPhotos = false,
|
||||
this.requireCharging = false,
|
||||
this.triggerDelay = 30,
|
||||
this.syncAlbums = false,
|
||||
});
|
||||
|
||||
BackupConfig copyWith({
|
||||
bool? enabled,
|
||||
bool? useCellularForVideos,
|
||||
bool? useCellularForPhotos,
|
||||
bool? requireCharging,
|
||||
int? triggerDelay,
|
||||
bool? syncAlbums,
|
||||
}) => BackupConfig(
|
||||
enabled: enabled ?? this.enabled,
|
||||
useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos,
|
||||
useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos,
|
||||
requireCharging: requireCharging ?? this.requireCharging,
|
||||
triggerDelay: triggerDelay ?? this.triggerDelay,
|
||||
syncAlbums: syncAlbums ?? this.syncAlbums,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is BackupConfig &&
|
||||
other.enabled == enabled &&
|
||||
other.useCellularForVideos == useCellularForVideos &&
|
||||
other.useCellularForPhotos == useCellularForPhotos &&
|
||||
other.requireCharging == requireCharging &&
|
||||
other.triggerDelay == triggerDelay &&
|
||||
other.syncAlbums == syncAlbums);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)';
|
||||
}
|
||||
@@ -62,6 +62,14 @@ enum MetadataKey<T extends Object> {
|
||||
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||
|
||||
// Backup
|
||||
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
|
||||
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
|
||||
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
|
||||
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
|
||||
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
|
||||
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
|
||||
enum Setting<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
||||
enableBackup<bool>(StoreKey.enableBackup, false);
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false);
|
||||
|
||||
const Setting(this.storeKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -6,26 +6,27 @@ enum StoreKey<T> {
|
||||
version<int>._(0),
|
||||
currentUser<UserDto>._(2),
|
||||
deviceId<String>._(4),
|
||||
backupRequireCharging<bool>._(7),
|
||||
backupTriggerDelay<int>._(8),
|
||||
serverUrl<String>._(10),
|
||||
accessToken<String>._(11),
|
||||
serverEndpoint<String>._(12),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
syncAlbums<bool>._(131),
|
||||
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
|
||||
// Experimental stuff
|
||||
enableBackup<bool>._(1003),
|
||||
useWifiForUploadVideos<bool>._(1004),
|
||||
useWifiForUploadPhotos<bool>._(1005),
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
reviewOutOfSyncChangesAndroid<bool>._(1014),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyBackupRequireCharging<bool>._(7),
|
||||
legacyBackupTriggerDelay<int>._(8),
|
||||
legacySyncAlbums<bool>._(131),
|
||||
legacyEnableBackup<bool>._(1003),
|
||||
legacyUseWifiForUploadVideos<bool>._(1004),
|
||||
legacyUseWifiForUploadPhotos<bool>._(1005),
|
||||
legacySelectedAlbumSortOrder<int>._(113),
|
||||
legacySelectedAlbumSortReverse<bool>._(123),
|
||||
legacyAlbumGridView<bool>._(140),
|
||||
|
||||
@@ -11,15 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
@@ -39,16 +38,15 @@ class BackgroundWorkerFgService {
|
||||
Future<void> saveNotificationMessage(String title, String body) =>
|
||||
_foregroundHostApi.saveNotificationMessage(title, body);
|
||||
|
||||
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
|
||||
BackgroundWorkerSettings(
|
||||
minimumDelaySeconds:
|
||||
minimumDelaySeconds ??
|
||||
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
|
||||
requiresCharging:
|
||||
requireCharging ??
|
||||
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
|
||||
),
|
||||
);
|
||||
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) {
|
||||
final backup = MetadataRepository.instance.appConfig.backup;
|
||||
return _foregroundHostApi.configure(
|
||||
BackgroundWorkerSettings(
|
||||
minimumDelaySeconds: minimumDelaySeconds ?? backup.triggerDelay,
|
||||
requiresCharging: requireCharging ?? backup.requireCharging,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disable() => _foregroundHostApi.disable();
|
||||
}
|
||||
@@ -71,7 +69,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
bool get _isBackupEnabled => _ref?.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup) ?? false;
|
||||
bool get _isBackupEnabled => MetadataRepository.instance.appConfig.backup.enabled;
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
|
||||
@@ -4,15 +4,12 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -23,35 +20,29 @@ class LocalSyncService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required DriftTrashSyncRepository trashSyncRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_trashSyncRepository = trashSyncRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
} else {
|
||||
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
await _syncTrashedAssets();
|
||||
await _trashSyncRepository.syncRestoresForRevivedAssets();
|
||||
}
|
||||
await _trashSyncRepository.recheckRemoteTrashCandidates();
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
// final assets = await _localAssetRepository.getEmptyCloudIdAssets();
|
||||
@@ -60,7 +51,11 @@ class LocalSyncService {
|
||||
|
||||
if (full || await _nativeSyncApi.shouldFullSync()) {
|
||||
_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();
|
||||
@@ -82,13 +77,13 @@ class LocalSyncService {
|
||||
);
|
||||
|
||||
final dbAlbums = await _localAlbumRepository.getAll();
|
||||
// On Android, we need to sync all albums since it is not possible to
|
||||
// detect album deletions from the native side
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
for (final album in dbAlbums) {
|
||||
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
|
||||
await _localAlbumRepository.syncDeletes(album.id, deviceIds);
|
||||
}
|
||||
|
||||
await _cleanupTrashSync();
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
@@ -362,7 +364,7 @@ class LocalSyncService {
|
||||
@visibleForTesting
|
||||
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
|
||||
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(
|
||||
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
|
||||
@@ -370,30 +372,6 @@ class LocalSyncService {
|
||||
|
||||
_log.fine("syncTrashedAssets, trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
|
||||
await _trashedLocalAssetRepository.processTrashSnapshot(trashedAssets);
|
||||
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||
}
|
||||
|
||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +416,6 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -27,15 +22,14 @@ enum SyncMigrationTask {
|
||||
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
|
||||
}
|
||||
|
||||
typedef _RemoteAssetTrashState = ({String id, DateTime? deletedAt, String? checksum});
|
||||
|
||||
class SyncStreamService {
|
||||
final Logger _logger = Logger('SyncStreamService');
|
||||
|
||||
final SyncApiRepository _syncApiRepository;
|
||||
final SyncStreamRepository _syncStreamRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final SyncMigrationRepository _syncMigrationRepository;
|
||||
final ApiService _api;
|
||||
final bool Function()? _cancelChecker;
|
||||
@@ -43,19 +37,13 @@ class SyncStreamService {
|
||||
SyncStreamService({
|
||||
required SyncApiRepository syncApiRepository,
|
||||
required SyncStreamRepository syncStreamRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required DriftTrashSyncRepository trashSyncRepository,
|
||||
required SyncMigrationRepository syncMigrationRepository,
|
||||
required ApiService api,
|
||||
bool Function()? cancelChecker,
|
||||
}) : _syncApiRepository = syncApiRepository,
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_trashSyncRepository = trashSyncRepository,
|
||||
_syncMigrationRepository = syncMigrationRepository,
|
||||
_api = api,
|
||||
_cancelChecker = cancelChecker;
|
||||
@@ -200,22 +188,24 @@ class SyncStreamService {
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
await _handleRemoteAssetTrashState(
|
||||
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
|
||||
);
|
||||
return;
|
||||
case SyncEntityType.assetV2:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV2>();
|
||||
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
await _handleRemoteAssetTrashState(
|
||||
remoteSyncAssets.map((e) => (id: e.id, deletedAt: e.deletedAt, checksum: e.checksum)),
|
||||
);
|
||||
return;
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final remoteDeletedAtByRemoteId = Map<String, DateTime>.fromEntries(
|
||||
remoteSyncAssets.map((e) => MapEntry(e.assetId, now)),
|
||||
);
|
||||
await _trashSyncRepository.recordRemoteTrash(remoteDeletedAtByRemoteId);
|
||||
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
|
||||
case SyncEntityType.assetExifV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||
@@ -486,58 +476,17 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return Future.value();
|
||||
} else {
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
await _trashLocalAssets(localAssetsToTrash);
|
||||
} else {
|
||||
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
|
||||
Future<void> _handleRemoteAssetTrashState(Iterable<_RemoteAssetTrashState> remoteSyncAssets) async {
|
||||
final deleted = <String, DateTime>{};
|
||||
final aliveChecksums = <String>[];
|
||||
for (final e in remoteSyncAssets) {
|
||||
if (e.deletedAt != null) {
|
||||
deleted[e.id] = e.deletedAt!;
|
||||
} else if (e.checksum != null) {
|
||||
aliveChecksums.add(e.checksum!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
await _trashSyncRepository.recordRemoteTrash(deleted);
|
||||
await _trashSyncRepository.recordRemoteRestore(aliveChecksums);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ enum TimelineOrigin {
|
||||
albumActivities,
|
||||
folder,
|
||||
recentlyAdded,
|
||||
syncTrash,
|
||||
}
|
||||
|
||||
class TimelineFactory {
|
||||
@@ -69,6 +70,8 @@ class TimelineFactory {
|
||||
|
||||
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
|
||||
|
||||
TimelineService toTrashSyncReview() => TimelineService(_timelineRepository.toTrashSyncReview(groupBy));
|
||||
|
||||
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
|
||||
|
||||
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));
|
||||
|
||||
@@ -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: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/utils/asset.mixin.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
enum TrashOrigin {
|
||||
// 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_album ON trashed_local_asset_entity (album_id)')
|
||||
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
class TrashedLocalAssetEntity extends LocalAssetEntity {
|
||||
const TrashedLocalAssetEntity();
|
||||
|
||||
TextColumn get id => 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 playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id, albumId};
|
||||
}
|
||||
|
||||
+633
-364
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/stack.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.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
@@ -56,6 +57,7 @@ import 'package:logging/logging.dart';
|
||||
TrashedLocalAssetEntity,
|
||||
AssetEditEntity,
|
||||
MetadataEntity,
|
||||
TrashSyncEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
@@ -98,7 +100,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 26;
|
||||
int get schemaVersion => 27;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -276,6 +278,16 @@ class Drift extends $Drift {
|
||||
from25To26: (m, v26) async {
|
||||
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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+13
-4
@@ -45,9 +45,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
|
||||
as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
|
||||
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 {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -94,9 +96,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
|
||||
this,
|
||||
);
|
||||
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
|
||||
late final i23.$TrashSyncEntityTable trashSyncEntity = i23
|
||||
.$TrashSyncEntityTable(this);
|
||||
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
|
||||
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -133,6 +137,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
metadataEntity,
|
||||
trashSyncEntity,
|
||||
i10.idxPartnerSharedWithId,
|
||||
i11.idxLatLng,
|
||||
i11.idxRemoteExifCity,
|
||||
@@ -145,6 +150,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i20.idxTrashedLocalAssetChecksum,
|
||||
i20.idxTrashedLocalAssetAlbum,
|
||||
i21.idxAssetEditAssetId,
|
||||
i23.idxTrashSyncDecision,
|
||||
i23.idxTrashSyncChecksum,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules
|
||||
@@ -397,4 +404,6 @@ class $DriftManager {
|
||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i22.$$MetadataEntityTableTableManager get metadataEntity =>
|
||||
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
|
||||
i23.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
|
||||
i23.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
|
||||
}
|
||||
|
||||
@@ -13539,6 +13539,714 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
|
||||
final class Schema27 extends i0.VersionedSchema {
|
||||
Schema27({required super.database}) : super(version: 27);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
metadata,
|
||||
trashSyncEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
idxTrashSyncDecision,
|
||||
idxTrashSyncChecksum,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape36 localAssetEntity = Shape36(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_159, _column_177],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final 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({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
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, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -13693,6 +14402,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from25To26(migrator, schema);
|
||||
return 26;
|
||||
case 26:
|
||||
final schema = Schema27(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from26To27(migrator, schema);
|
||||
return 27;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -13725,6 +14439,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -13752,5 +14467,6 @@ i1.OnUpgrade stepByStep({
|
||||
from23To24: from23To24,
|
||||
from24To25: from24To25,
|
||||
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/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/remote_deleted_local_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
@@ -109,41 +110,70 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return query.map((localAlbum) => localAlbum.toDto()).get();
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return {};
|
||||
Future<List<RemoteDeletedLocalAsset>> getRemoteTrashCandidates(
|
||||
Map<String, DateTime> remoteDeletedAtByRemoteId,
|
||||
) async {
|
||||
if (remoteDeletedAtByRemoteId.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
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();
|
||||
|
||||
final byLocalId = <String, RemoteDeletedLocalAsset>{};
|
||||
for (final slice in remoteDeletedAtByRemoteId.keys.toSet().slices(kDriftMaxChunk)) {
|
||||
final rows = await _remoteTrashCandidatesQuery(slice).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);
|
||||
final assetData = row.readTable(_db.localAssetEntity);
|
||||
final remoteId = row.read(_db.remoteAssetEntity.id)!;
|
||||
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(
|
||||
@@ -214,6 +244,20 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
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 {
|
||||
await _db.customUpdate(
|
||||
'''
|
||||
|
||||
@@ -152,6 +152,14 @@ extension<T extends Object> on MetadataDomain<T> {
|
||||
isReverse: repo._read(.albumIsReverse),
|
||||
isGrid: repo._read(.albumIsGrid),
|
||||
),
|
||||
backup: .new(
|
||||
enabled: repo._read(.backupEnabled),
|
||||
useCellularForVideos: repo._read(.backupUseCellularForVideos),
|
||||
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
|
||||
requireCharging: repo._read(.backupRequireCharging),
|
||||
triggerDelay: repo._read(.backupTriggerDelay),
|
||||
syncAlbums: repo._read(.backupSyncAlbums),
|
||||
),
|
||||
);
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(
|
||||
|
||||
@@ -4,12 +4,14 @@ import 'package:drift/drift.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.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/map.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
@@ -346,6 +348,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
joinLocal: true,
|
||||
);
|
||||
|
||||
TimelineQuery toTrashSyncReview(GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchTrashSyncBucket(groupBy: groupBy),
|
||||
assetSource: (offset, count) => _getToTrashSyncBucketAssets(offset: offset, count: count),
|
||||
origin: TimelineOrigin.syncTrash,
|
||||
);
|
||||
|
||||
TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||
filter: (row) =>
|
||||
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
|
||||
@@ -678,6 +686,58 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
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) {
|
||||
|
||||
@@ -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/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_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/trashed_local_asset.entity.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());
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if (trashedAssets.isEmpty) {
|
||||
await _db.delete(_db.trashedLocalAssetEntity).go();
|
||||
@@ -86,7 +83,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
isFavorite: Value(item.asset.isFavorite),
|
||||
orientation: Value(item.asset.orientation),
|
||||
playbackStyle: Value(item.asset.playbackStyle),
|
||||
source: TrashOrigin.localSync,
|
||||
source: .localSync,
|
||||
);
|
||||
|
||||
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
|
||||
@@ -104,9 +101,11 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
_db.trashedLocalAssetEntity,
|
||||
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
|
||||
final idToDelete = existingIds.where((id) => !assetIds.contains(id));
|
||||
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||
}
|
||||
await _db.batch((batch) {
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
|
||||
Future<void> trashLocalAssets(Map<String, Iterable<RemoteDeletedLocalAsset>> assetsByAlbums) async {
|
||||
if (assetsByAlbums.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
@@ -134,7 +133,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
final idToDelete = <String>{};
|
||||
|
||||
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);
|
||||
companions.add(
|
||||
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
|
||||
Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
|
||||
final localChecksumById = <String, String>{};
|
||||
|
||||
@@ -8,13 +8,13 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.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/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/common/search_field.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
|
||||
_enableSyncUploadAlbum.value = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
||||
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
ref.read(backupAlbumProvider.notifier).getAll();
|
||||
|
||||
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
return;
|
||||
}
|
||||
|
||||
final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
||||
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
final selectedAlbums = ref
|
||||
.read(backupAlbumProvider)
|
||||
.where((a) => a.backupSelection == BackupSelection.selected)
|
||||
@@ -103,7 +103,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
return;
|
||||
}
|
||||
|
||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled;
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
|
||||
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
|
||||
|
||||
@@ -3,14 +3,12 @@ import 'dart:async';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
@@ -21,18 +19,20 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
bool hasPopped = false;
|
||||
final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
||||
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
||||
final previousBackup = ref.read(metadataProvider).appConfig.backup;
|
||||
final previousCellularForVideos = previousBackup.useCellularForVideos;
|
||||
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
// There is an issue with Flutter where the pop event
|
||||
// can be triggered multiple times, so we guard it with _hasPopped
|
||||
|
||||
final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
|
||||
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
|
||||
final currentBackup = ref.read(metadataProvider).appConfig.backup;
|
||||
final currentCellularForVideos = currentBackup.useCellularForVideos;
|
||||
final currentCellularForPhotos = currentBackup.useCellularForPhotos;
|
||||
|
||||
if (currentWifiReqForVideos == previousWifiReqForVideos &&
|
||||
currentWifiReqForPhotos == previousWifiReqForPhotos) {
|
||||
if (currentCellularForVideos == previousCellularForVideos &&
|
||||
currentCellularForPhotos == previousCellularForPhotos) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
|
||||
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
final isBackupEnabled = MetadataRepository.instance.appConfig.backup.enabled;
|
||||
if (!isBackupEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
@@ -340,7 +341,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
await backgroundManager.hashAssets();
|
||||
}
|
||||
|
||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
||||
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -369,7 +370,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
|
||||
final isEnableBackup = Store.get(StoreKey.enableBackup, false);
|
||||
final isEnableBackup = MetadataRepository.instance.appConfig.backup.enabled;
|
||||
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
+76
@@ -654,6 +654,82 @@ class NativeSyncApi {
|
||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
+65
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+98
@@ -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:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -39,29 +44,50 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
|
||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
if (isInTrash && isOwner && asset.hasRemote && !isSyncTrashTimeline)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (isSyncTrashTimeline) ...[
|
||||
KeepOnDeviceActionButton(
|
||||
source: ActionSource.viewer,
|
||||
onResult: (result) {
|
||||
showKeepResultToast(context, result);
|
||||
_updateView(result, ref);
|
||||
},
|
||||
),
|
||||
MoveToTrashActionButton(
|
||||
source: ActionSource.viewer,
|
||||
onResult: (result) {
|
||||
showTrashResultToast(context, result);
|
||||
_updateView(result, ref);
|
||||
},
|
||||
),
|
||||
] else ...[
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -35,6 +36,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
||||
final isWaitingForTrashApproval = ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
|
||||
|
||||
final actionContext = ActionButtonContext(
|
||||
asset: asset,
|
||||
@@ -48,6 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
source: ActionSource.viewer,
|
||||
isCasting: isCasting,
|
||||
timelineOrigin: timelineOrigin,
|
||||
isWaitingForTrashApproval: isWaitingForTrashApproval,
|
||||
);
|
||||
|
||||
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:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
@@ -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/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -47,6 +50,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final isWaitingForSyncApproval =
|
||||
ref.read(timelineServiceProvider).origin == TimelineOrigin.syncTrash ||
|
||||
ref.watch(isWaitingForTrashApprovalProvider(asset.checksum)).value == true;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
@@ -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),
|
||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||
if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
|
||||
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
|
||||
ViewerKebabMenu(originalTheme: originalTheme),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
class BackupToggleButton extends ConsumerStatefulWidget {
|
||||
final VoidCallback onStart;
|
||||
@@ -31,7 +31,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
end: 1,
|
||||
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
|
||||
|
||||
_isEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
_isEnabled = ref.read(metadataProvider).appConfig.backup.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -41,7 +41,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
}
|
||||
|
||||
Future<void> _onToggle(bool value) async {
|
||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.enableBackup, value);
|
||||
await ref.read(metadataProvider).write(MetadataKey.backupEnabled, value);
|
||||
|
||||
setState(() {
|
||||
_isEnabled = value;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,15 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.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/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
enum AppLifeCycleEnum { active, inactive, paused, resumed, detached, hidden }
|
||||
@@ -108,7 +107,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
|
||||
final isAlbumLinkedSyncEnable = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
|
||||
try {
|
||||
bool syncSuccess = false;
|
||||
@@ -138,7 +137,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup() async {
|
||||
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
|
||||
final isEnableBackup = _ref.read(metadataProvider).appConfig.backup.enabled;
|
||||
|
||||
if (isEnableBackup) {
|
||||
final currentUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
@@ -2,3 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());
|
||||
|
||||
final appSettingStreamProvider = StreamProvider.family.autoDispose<bool, AppSettingsEnum<bool>>((ref, setting) {
|
||||
final service = ref.watch(appSettingsServiceProvider);
|
||||
return service.watchSetting(setting);
|
||||
});
|
||||
|
||||
@@ -550,6 +550,21 @@ class ActionNotifier extends Notifier<void> {
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> resolveRemoteTrash(ActionSource source, {required bool keep}) async {
|
||||
final selectedLocalIds = _getAssets(source).map((a) => a.localId).nonNulls.toSet();
|
||||
_logger.info('resolveRemoteTrash, selectedLocalIds: $selectedLocalIds, keep: $keep');
|
||||
if (selectedLocalIds.isEmpty) {
|
||||
return const ActionResult(count: 0, success: false, error: 'Failed to select asset(s)');
|
||||
}
|
||||
try {
|
||||
final result = await _service.resolveRemoteTrash(selectedLocalIds, keep: keep);
|
||||
return ActionResult(count: result.displayCount, success: result.success);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to ${keep ? 'keep' : 'trash'} assets', error, stack);
|
||||
return ActionResult(count: selectedLocalIds.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<RemoteAsset> {
|
||||
|
||||
@@ -11,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/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
|
||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||
|
||||
@@ -20,10 +19,7 @@ final syncStreamServiceProvider = Provider(
|
||||
(ref) => SyncStreamService(
|
||||
syncApiRepository: ref.watch(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
|
||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||
api: ref.watch(apiServiceProvider),
|
||||
cancelChecker: ref.watch(cancellationProvider),
|
||||
@@ -39,8 +35,7 @@ final localSyncServiceProvider = Provider(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
typedef TrashedAssetsCount = ({int total, int hashed});
|
||||
|
||||
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>((ref) {
|
||||
return DriftTrashSyncRepository(
|
||||
ref.watch(driftProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
|
||||
final repo = ref.watch(trashedLocalAssetRepository);
|
||||
final total$ = repo.watchCount();
|
||||
final hashed$ = repo.watchHashedCount();
|
||||
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
|
||||
});
|
||||
|
||||
final outOfSyncAssetsCountProvider = StreamProvider<int>((ref) {
|
||||
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
|
||||
final repo = ref.watch(trashSyncRepositoryProvider);
|
||||
return enabledReviewMode.when(
|
||||
data: (enabled) => enabled ? repo.watchPendingReviewCount() : Stream<int>.value(0),
|
||||
loading: () => Stream<int>.value(0),
|
||||
error: (_, __) => Stream<int>.value(0),
|
||||
);
|
||||
});
|
||||
|
||||
final isWaitingForTrashApprovalProvider = StreamProvider.family<bool, String?>((ref, checksum) {
|
||||
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
|
||||
final repo = ref.watch(trashSyncRepositoryProvider);
|
||||
return enabledReviewMode.when(
|
||||
data: (enabled) => enabled && checksum != null ? repo.watchIsAssetPendingByChecksum(checksum) : Stream.value(false),
|
||||
loading: () => Stream.value(false),
|
||||
error: (_, __) => Stream.value(false),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
@@ -192,7 +193,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
||||
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
try {
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) {
|
||||
@@ -213,7 +214,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false);
|
||||
final isSyncAlbumEnabled = _ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
try {
|
||||
unawaited(
|
||||
_ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) {
|
||||
|
||||
@@ -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/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._assetApiRepository);
|
||||
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -45,6 +50,54 @@ class AssetMediaRepository {
|
||||
return PhotoManager.editor.deleteWithIds(ids);
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
try {
|
||||
return await _nativeSyncApi.requestManageMediaPermission();
|
||||
} catch (e, s) {
|
||||
_log.warning('Error requesting manage media permission', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
try {
|
||||
return await _nativeSyncApi.hasManageMediaPermission();
|
||||
} catch (e, s) {
|
||||
_log.warning('Error requesting manage media permission state', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
try {
|
||||
return await _nativeSyncApi.manageMediaPermission();
|
||||
} catch (e, s) {
|
||||
_log.warning('Error requesting manage media permission settings', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
|
||||
try {
|
||||
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
|
||||
} catch (e, s) {
|
||||
_log.warning('Error restore file from trash by Id', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
|
||||
final result = await _restoreFromTrashById(asset.id, asset.type.index);
|
||||
if (result) {
|
||||
restoredIds.add(asset.id);
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
|
||||
Future<AssetEntity?> get(String id) async {
|
||||
final entity = await AssetEntity.fromId(id);
|
||||
return entity;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_trash_sync_review.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||
@@ -163,6 +164,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftTrashRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftTrashSyncReviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
|
||||
@@ -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
|
||||
/// [DriftUploadDetailPage]
|
||||
class DriftUploadDetailRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -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/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
@@ -34,7 +35,7 @@ final actionServiceProvider = Provider<ActionService>(
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(trashedLocalAssetRepository),
|
||||
ref.watch(trashSyncRepositoryProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(downloadRepositoryProvider),
|
||||
ref.watch(tagServiceProvider),
|
||||
@@ -47,7 +48,7 @@ class ActionService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final DriftTrashSyncRepository _trashSyncRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final DownloadRepository _downloadRepository;
|
||||
final TagService _tagService;
|
||||
@@ -58,7 +59,7 @@ class ActionService {
|
||||
this._localAssetRepository,
|
||||
this._albumApiRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._trashedLocalAssetRepository,
|
||||
this._trashSyncRepository,
|
||||
this._assetMediaRepository,
|
||||
this._downloadRepository,
|
||||
this._tagService,
|
||||
@@ -298,10 +299,16 @@ class ActionService {
|
||||
return 0;
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
|
||||
} else {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
await _trashSyncRepository.recordUserManualTrash(deletedIds);
|
||||
}
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
return deletedIds.length;
|
||||
}
|
||||
|
||||
/// Apply a user review decision. The HIGH atomicity bug from the
|
||||
/// original PR cannot recur — `DriftTrashSyncRepository` owns the single
|
||||
/// transactional surface.
|
||||
Future<RemoteTrashResolveResult> resolveRemoteTrash(Iterable<String> localAssetIds, {required bool keep}) {
|
||||
return _trashSyncRepository.applyReviewDecision(localAssetIds, keep: keep);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
enum AppSettingsEnum<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
reviewOutOfSyncChangesAndroid<bool>(StoreKey.reviewOutOfSyncChangesAndroid, null, false),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
||||
enableBackup<bool>(StoreKey.enableBackup, null, false),
|
||||
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
|
||||
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
|
||||
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
|
||||
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
|
||||
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
@@ -29,4 +24,11 @@ class AppSettingsService {
|
||||
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
|
||||
return Store.put(setting.storeKey, value);
|
||||
}
|
||||
|
||||
Stream<T> watchSetting<T>(AppSettingsEnum<T> setting) async* {
|
||||
yield getSetting<T>(setting);
|
||||
await for (final dynamic value in Store.watch(setting.storeKey)) {
|
||||
yield (value as T?) ?? setting.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/network.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -25,7 +25,6 @@ final authServiceProvider = Provider(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(networkServiceProvider),
|
||||
ref.watch(backgroundSyncProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -35,7 +34,6 @@ class AuthService {
|
||||
final ApiService _apiService;
|
||||
final NetworkService _networkService;
|
||||
final BackgroundSyncManager _backgroundSyncManager;
|
||||
final AppSettingsService _appSettingsService;
|
||||
final _log = Logger("AuthService");
|
||||
|
||||
AuthService(
|
||||
@@ -44,7 +42,6 @@ class AuthService {
|
||||
this._apiService,
|
||||
this._networkService,
|
||||
this._backgroundSyncManager,
|
||||
this._appSettingsService,
|
||||
);
|
||||
|
||||
/// Validates the provided server URL by resolving and setting the endpoint.
|
||||
@@ -103,7 +100,7 @@ class AuthService {
|
||||
_log.severe("Error clearing local data", error, stackTrace);
|
||||
});
|
||||
|
||||
await _appSettingsService.setSetting(AppSettingsEnum.enableBackup, false);
|
||||
await MetadataRepository.instance.write(MetadataKey.backupEnabled, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,13 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.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/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -31,7 +30,6 @@ final backgroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(storageRepositoryProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
);
|
||||
|
||||
@@ -105,7 +103,6 @@ class BackgroundUploadService {
|
||||
this._storageRepository,
|
||||
this._localAssetRepository,
|
||||
this._backupRepository,
|
||||
this._appSettingsService,
|
||||
this._assetMediaRepository,
|
||||
) {
|
||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||
@@ -116,7 +113,6 @@ class BackgroundUploadService {
|
||||
final StorageRepository _storageRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final AppSettingsService _appSettingsService;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final Logger _logger = Logger('BackgroundUploadService');
|
||||
|
||||
@@ -363,15 +359,14 @@ class BackgroundUploadService {
|
||||
}
|
||||
|
||||
bool _shouldRequireWiFi(LocalAsset asset) {
|
||||
bool requiresWiFi = true;
|
||||
|
||||
if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
|
||||
requiresWiFi = false;
|
||||
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
|
||||
requiresWiFi = false;
|
||||
final backup = MetadataRepository.instance.appConfig.backup;
|
||||
if (asset.isVideo && backup.useCellularForVideos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requiresWiFi;
|
||||
if (!asset.isVideo && backup.useCellularForPhotos) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<UploadTask> buildUploadTask(
|
||||
|
||||
@@ -7,18 +7,17 @@ import 'package:immich_mobile/domain/models/asset/asset_metadata.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/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
@@ -39,7 +38,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(storageRepositoryProvider),
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
);
|
||||
});
|
||||
@@ -55,7 +53,6 @@ class ForegroundUploadService {
|
||||
this._storageRepository,
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._appSettingsService,
|
||||
this._assetMediaRepository,
|
||||
);
|
||||
|
||||
@@ -63,7 +60,6 @@ class ForegroundUploadService {
|
||||
final StorageRepository _storageRepository;
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AppSettingsService _appSettingsService;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
@@ -455,14 +451,13 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
bool _shouldRequireWiFi(LocalAsset asset) {
|
||||
bool requiresWiFi = true;
|
||||
|
||||
if (asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)) {
|
||||
requiresWiFi = false;
|
||||
} else if (!asset.isVideo && _appSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)) {
|
||||
requiresWiFi = false;
|
||||
final backup = MetadataRepository.instance.appConfig.backup;
|
||||
if (asset.isVideo && backup.useCellularForVideos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requiresWiFi;
|
||||
if (!asset.isVideo && backup.useCellularForPhotos) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ class ImmichTheme {
|
||||
|
||||
ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale}) {
|
||||
final isDark = colorScheme.brightness == Brightness.dark;
|
||||
final warningColor = isDark ? const Color(0xFFF3BC6A) : const Color(0xFFC47A00);
|
||||
final onWarningColor = isDark ? Colors.black : Colors.white;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: colorScheme.brightness,
|
||||
colorScheme: colorScheme,
|
||||
colorScheme: colorScheme.copyWith(tertiary: warningColor, onTertiary: onWarningColor),
|
||||
primaryColor: colorScheme.primary,
|
||||
hintColor: colorScheme.onSurfaceSecondary,
|
||||
focusColor: colorScheme.primary,
|
||||
|
||||
@@ -47,6 +47,7 @@ class ActionButtonContext {
|
||||
final bool isCasting;
|
||||
final TimelineOrigin timelineOrigin;
|
||||
final int selectedCount;
|
||||
final bool isWaitingForTrashApproval;
|
||||
|
||||
const ActionButtonContext({
|
||||
required this.asset,
|
||||
@@ -61,6 +62,7 @@ class ActionButtonContext {
|
||||
this.isCasting = false,
|
||||
this.timelineOrigin = TimelineOrigin.main,
|
||||
this.selectedCount = 1,
|
||||
this.isWaitingForTrashApproval = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +104,8 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
!context.isArchived,
|
||||
!context.isArchived &&
|
||||
!context.isWaitingForTrashApproval,
|
||||
ActionButtonType.unarchive =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
@@ -117,31 +120,37 @@ enum ActionButtonType {
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isTrashEnabled && //
|
||||
context.timelineOrigin != TimelineOrigin.trash,
|
||||
context.timelineOrigin != TimelineOrigin.trash &&
|
||||
!context.isWaitingForTrashApproval,
|
||||
ActionButtonType.restoreTrash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.timelineOrigin == TimelineOrigin.trash,
|
||||
context.timelineOrigin == TimelineOrigin.trash &&
|
||||
!context.isWaitingForTrashApproval,
|
||||
ActionButtonType.deletePermanent =>
|
||||
context.isOwner && //
|
||||
context.asset.hasRemote && //
|
||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
|
||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView) &&
|
||||
!context.isWaitingForTrashApproval,
|
||||
ActionButtonType.delete =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
context.asset.hasRemote &&
|
||||
!context.isWaitingForTrashApproval,
|
||||
ActionButtonType.moveToLockFolder =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
context.asset.hasRemote &&
|
||||
!context.isWaitingForTrashApproval,
|
||||
ActionButtonType.removeFromLockFolder =>
|
||||
context.isOwner && //
|
||||
context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.deleteLocal =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasLocal,
|
||||
context.asset.hasLocal &&
|
||||
!context.isWaitingForTrashApproval,
|
||||
ActionButtonType.upload =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.storage == AssetState.local,
|
||||
@@ -179,6 +188,7 @@ enum ActionButtonType {
|
||||
context.timelineOrigin != TimelineOrigin.lockedFolder &&
|
||||
context.timelineOrigin != TimelineOrigin.archive &&
|
||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||
context.timelineOrigin != TimelineOrigin.syncTrash &&
|
||||
context.isOwner,
|
||||
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||
ActionButtonType.slideshow => true,
|
||||
|
||||
@@ -124,6 +124,13 @@ Future<void> _migrateTo26(Drift drift) async {
|
||||
await _migrateAlbumSortMode(migrator);
|
||||
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, MetadataKey.albumIsReverse);
|
||||
await migrator.migrateBool(StoreKey.legacyAlbumGridView, MetadataKey.albumIsGrid);
|
||||
// Backup
|
||||
await migrator.migrateBool(StoreKey.legacyEnableBackup, MetadataKey.backupEnabled);
|
||||
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadVideos, MetadataKey.backupUseCellularForVideos);
|
||||
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadPhotos, MetadataKey.backupUseCellularForPhotos);
|
||||
await migrator.migrateBool(StoreKey.legacyBackupRequireCharging, MetadataKey.backupRequireCharging);
|
||||
await migrator.migrateInt(StoreKey.legacyBackupTriggerDelay, MetadataKey.backupTriggerDelay);
|
||||
await migrator.migrateBool(StoreKey.legacySyncAlbums, MetadataKey.backupSyncAlbums);
|
||||
await migrator.complete();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/pages/common/settings.page.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/locale_provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
@@ -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(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.standard,
|
||||
contentPadding: const EdgeInsets.only(left: 30, right: 30),
|
||||
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(
|
||||
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(),
|
||||
onTap: onTap,
|
||||
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() {
|
||||
return buildActionButton(
|
||||
Icons.assignment_outlined,
|
||||
@@ -269,6 +295,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
buildOutOfSyncButton(),
|
||||
if (isReadonlyModeEnabled) buildReadonlyMessage(),
|
||||
buildAppLogButton(),
|
||||
buildFreeUpSpaceButton(),
|
||||
|
||||
@@ -6,13 +6,13 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
@@ -112,6 +112,7 @@ class _ProfileIndicator extends ConsumerWidget {
|
||||
// TODO: remove this when update Flutter version newer than 3.35.7
|
||||
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
|
||||
|
||||
final outOfSyncCount = ref.watch(outOfSyncAssetsCountProvider).value ?? 0;
|
||||
void toggleReadonlyMode() {
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
|
||||
@@ -148,7 +149,7 @@ class _ProfileIndicator extends ConsumerWidget {
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
alignment: Alignment.bottomRight,
|
||||
isLabelVisible: versionWarningPresent,
|
||||
isLabelVisible: versionWarningPresent || outOfSyncCount > 0,
|
||||
offset: const Offset(-2, -12),
|
||||
child: user == null
|
||||
? const Icon(Icons.face_outlined, size: widgetSize)
|
||||
@@ -193,64 +194,51 @@ class _BackupIndicator extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
|
||||
final backupStateStream = ref.watch(settingsProvider).watch(Setting.enableBackup);
|
||||
final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled));
|
||||
final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
final iconColor = isDarkTheme ? Colors.white : Colors.black;
|
||||
final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
|
||||
|
||||
return StreamBuilder(
|
||||
stream: backupStateStream,
|
||||
initialData: false,
|
||||
builder: (ctx, snapshot) {
|
||||
final backupEnabled = snapshot.data ?? false;
|
||||
if (!backupEnabled) {
|
||||
return _BadgeLabel(
|
||||
Icon(Icons.cloud_off_rounded, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!backupEnabled) {
|
||||
return _BadgeLabel(
|
||||
Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 9,
|
||||
color: iconColor,
|
||||
semanticLabel: 'backup_controller_page_backup'.tr(),
|
||||
if (hasError) {
|
||||
return _BadgeLabel(
|
||||
Icon(
|
||||
Icons.warning_rounded,
|
||||
size: 12,
|
||||
color: context.colorScheme.error,
|
||||
semanticLabel: 'backup_controller_page_backup'.tr(),
|
||||
),
|
||||
backgroundColor: context.colorScheme.errorContainer,
|
||||
);
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
return _BadgeLabel(
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3.5),
|
||||
child: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return _BadgeLabel(
|
||||
Icon(
|
||||
Icons.warning_rounded,
|
||||
size: 12,
|
||||
color: context.colorScheme.error,
|
||||
semanticLabel: 'backup_controller_page_backup'.tr(),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
strokeCap: StrokeCap.round,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
|
||||
semanticsLabel: 'backup_controller_page_backup'.tr(),
|
||||
),
|
||||
backgroundColor: context.colorScheme.errorContainer,
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
return _BadgeLabel(
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3.5),
|
||||
child: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
progressIndicatorTheme: context.themeData.progressIndicatorTheme.copyWith(year2023: true),
|
||||
),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
strokeCap: StrokeCap.round,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
|
||||
semanticsLabel: 'backup_controller_page_backup'.tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _BadgeLabel(
|
||||
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
|
||||
);
|
||||
},
|
||||
return _BadgeLabel(
|
||||
Icon(Icons.check_outlined, size: 9, color: iconColor, semanticLabel: 'backup_controller_page_backup'.tr()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,14 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/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/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
@@ -186,13 +187,13 @@ class LoginForm extends HookConsumerWidget {
|
||||
await backgroundManager.syncRemote();
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
if (Store.get(StoreKey.syncAlbums, false)) {
|
||||
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||
await backgroundManager.syncLinkedAlbum();
|
||||
}
|
||||
}
|
||||
|
||||
getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||
final hasPermission = await ref.read(assetMediaRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -223,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
ref.read(assetMediaRepositoryProvider).requestManageMediaPermission();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
|
||||
@@ -7,17 +7,20 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/repositories/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/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -28,9 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
|
||||
useValueChanged(
|
||||
@@ -56,11 +57,6 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
useEffect(() {
|
||||
() async {
|
||||
isManageMediaSupported.value = await checkAndroidVersion();
|
||||
if (isManageMediaSupported.value) {
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(localFilesManagerRepositoryProvider)
|
||||
.hasManageMediaPermission();
|
||||
}
|
||||
}();
|
||||
return null;
|
||||
}, []);
|
||||
@@ -72,36 +68,11 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
title: "advanced_settings_troubleshooting_title".tr(),
|
||||
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
|
||||
),
|
||||
if (isManageMediaSupported.value)
|
||||
Column(
|
||||
children: [
|
||||
SettingsSwitchListTile(
|
||||
enabled: true,
|
||||
valueNotifier: manageLocalMediaAndroid,
|
||||
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
manageMediaAndroidPermission.value = result;
|
||||
}
|
||||
},
|
||||
),
|
||||
SettingsActionTile(
|
||||
title: "manage_media_access_title".tr(),
|
||||
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
|
||||
subtitle: "manage_media_access_rationale".tr(),
|
||||
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
|
||||
? const Color.fromARGB(255, 243, 188, 106)
|
||||
: null,
|
||||
onActionTap: () async {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = result;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Android 12+: full selector (Off / Auto sync / Review) + MANAGE_MEDIA tile.
|
||||
// iOS: reduced selector (Off / Review) — no MANAGE_MEDIA on this
|
||||
// platform; auto-sync is dropped because PhotoKit prompts on
|
||||
// every batch, which would defeat the "set and forget" intent.
|
||||
if (isManageMediaSupported.value || Platform.isIOS) const _TrashSyncModeSelector(),
|
||||
SettingsSliderListTile(
|
||||
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
|
||||
valueNotifier: levelId,
|
||||
@@ -178,3 +149,135 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
return SettingsSubPageScaffold(settings: advancedSettings);
|
||||
}
|
||||
}
|
||||
|
||||
enum _TrashSyncMode { none, auto, review }
|
||||
|
||||
final _manageMediaPermissionProvider = FutureProvider<bool>((ref) async {
|
||||
return ref.watch(assetMediaRepositoryProvider).hasManageMediaPermission();
|
||||
});
|
||||
|
||||
class _TrashSyncModeSelector extends HookConsumerWidget {
|
||||
const _TrashSyncModeSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final autoSyncChanges = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final reviewOutOfSyncChanges = useAppSettingsState(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
|
||||
|
||||
final manageMediaAndroidPermission = ref.watch(_manageMediaPermissionProvider);
|
||||
final manageMediaAndroidPermissionValue = manageMediaAndroidPermission.valueOrNull;
|
||||
|
||||
final selectedTrashSyncMode = autoSyncChanges.value
|
||||
? _TrashSyncMode.auto
|
||||
: reviewOutOfSyncChanges.value
|
||||
? _TrashSyncMode.review
|
||||
: _TrashSyncMode.none;
|
||||
|
||||
Future<void> attemptToEnableSetting(AppSettingsEnum key) async {
|
||||
if (Platform.isIOS) {
|
||||
// No MANAGE_MEDIA on iOS; review is the only mode the user can pick.
|
||||
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
|
||||
reviewOutOfSyncChanges.value = true;
|
||||
autoSyncChanges.value = false;
|
||||
}
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
return;
|
||||
}
|
||||
final result = await ref.read(assetMediaRepositoryProvider).requestManageMediaPermission();
|
||||
ref.invalidate(_manageMediaPermissionProvider);
|
||||
if (key == AppSettingsEnum.manageLocalMediaAndroid) {
|
||||
autoSyncChanges.value = result;
|
||||
if (result) {
|
||||
reviewOutOfSyncChanges.value = false;
|
||||
}
|
||||
}
|
||||
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
|
||||
reviewOutOfSyncChanges.value = result;
|
||||
if (result) {
|
||||
autoSyncChanges.value = false;
|
||||
}
|
||||
}
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
}
|
||||
|
||||
Future<void> handleTrashSyncModeChange(_TrashSyncMode? mode) async {
|
||||
if (mode == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case _TrashSyncMode.none:
|
||||
if (!autoSyncChanges.value && !reviewOutOfSyncChanges.value) {
|
||||
break;
|
||||
}
|
||||
autoSyncChanges.value = false;
|
||||
reviewOutOfSyncChanges.value = false;
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
break;
|
||||
case _TrashSyncMode.auto:
|
||||
if (autoSyncChanges.value) {
|
||||
break;
|
||||
}
|
||||
await attemptToEnableSetting(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
break;
|
||||
case _TrashSyncMode.review:
|
||||
if (reviewOutOfSyncChanges.value) {
|
||||
break;
|
||||
}
|
||||
await attemptToEnableSetting(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsSubTitle(title: "advanced_settings_sync_remote_deletions_selector_title".tr()),
|
||||
SettingsRadioListTile(
|
||||
groups: [
|
||||
SettingsRadioGroup(
|
||||
title: 'off'.tr(),
|
||||
subtitle: 'advanced_settings_sync_remote_deletions_off_subtitle'.tr(),
|
||||
value: _TrashSyncMode.none,
|
||||
),
|
||||
// Auto-sync requires MANAGE_MEDIA to run silently. iOS has no
|
||||
// equivalent permission and every batch would trigger a PhotoKit
|
||||
// prompt — so the auto mode is intentionally hidden there.
|
||||
if (!Platform.isIOS)
|
||||
SettingsRadioGroup(
|
||||
title: 'advanced_settings_sync_remote_deletions_title'.tr(),
|
||||
subtitle: 'advanced_settings_sync_remote_deletions_subtitle'.tr(),
|
||||
value: _TrashSyncMode.auto,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'advanced_settings_review_remote_deletions_title'.tr(),
|
||||
subtitle: 'advanced_settings_review_remote_deletions_subtitle'.tr(),
|
||||
value: _TrashSyncMode.review,
|
||||
),
|
||||
],
|
||||
groupBy: selectedTrashSyncMode,
|
||||
onRadioChanged: (mode) => handleTrashSyncModeChange(mode),
|
||||
),
|
||||
// MANAGE_MEDIA permission tile is Android-only; iOS has no equivalent.
|
||||
if (!Platform.isIOS)
|
||||
SettingsActionTile(
|
||||
title: "manage_media_access_title".tr(),
|
||||
statusText: manageMediaAndroidPermissionValue == null
|
||||
? null
|
||||
: manageMediaAndroidPermissionValue == true
|
||||
? "allowed".tr()
|
||||
: "not_allowed".tr(),
|
||||
subtitle: "manage_media_access_rationale".tr(),
|
||||
statusColor:
|
||||
manageMediaAndroidPermissionValue == false && (autoSyncChanges.value || reviewOutOfSyncChanges.value)
|
||||
? const Color.fromARGB(255, 243, 188, 106)
|
||||
: null,
|
||||
onActionTap: () async {
|
||||
await ref.read(assetMediaRepositoryProvider).manageMediaPermission();
|
||||
ref.invalidate(_manageMediaPermissionProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,17 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.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/user.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
@@ -31,8 +30,8 @@ class DriftBackupSettings extends ConsumerWidget {
|
||||
title: "network_requirements".t(context: context),
|
||||
icon: Icons.cell_tower,
|
||||
),
|
||||
const _UseWifiForUploadVideosButton(),
|
||||
const _UseWifiForUploadPhotosButton(),
|
||||
const _UseCellularForVideosButton(),
|
||||
const _UseCellularForPhotosButton(),
|
||||
if (CurrentPlatform.isAndroid) ...[
|
||||
const Divider(),
|
||||
SettingGroupTitle(
|
||||
@@ -99,64 +98,58 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final albumSyncEnable = ref.watch(appConfigProvider.select((c) => c.backup.syncAlbums));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
StreamBuilder(
|
||||
stream: Store.watch(StoreKey.syncAlbums),
|
||||
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
|
||||
builder: (context, snapshot) {
|
||||
final albumSyncEnable = snapshot.data ?? false;
|
||||
return Column(
|
||||
children: [
|
||||
SettingListTile(
|
||||
title: "sync_albums".t(context: context),
|
||||
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
|
||||
trailing: Switch(
|
||||
value: albumSyncEnable,
|
||||
onChanged: (bool newValue) async {
|
||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
|
||||
Column(
|
||||
children: [
|
||||
SettingListTile(
|
||||
title: "sync_albums".t(context: context),
|
||||
subtitle: "sync_upload_album_setting_subtitle".t(context: context),
|
||||
trailing: Switch(
|
||||
value: albumSyncEnable,
|
||||
onChanged: (bool newValue) async {
|
||||
await ref.read(metadataProvider).write(MetadataKey.backupSyncAlbums, newValue);
|
||||
|
||||
if (newValue == true) {
|
||||
await _manageLinkedAlbums();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: albumSyncEnable ? 1.0 : 0.0,
|
||||
child: albumSyncEnable
|
||||
? SettingListTile(
|
||||
onTap: _manualSyncAlbums,
|
||||
contentPadding: const EdgeInsets.only(left: 32, right: 16),
|
||||
title: "organize_into_albums".t(context: context),
|
||||
subtitle: "organize_into_albums_description".t(context: context),
|
||||
trailing: isAlbumSyncInProgress
|
||||
? const SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: _manualSyncAlbums,
|
||||
icon: const Icon(Icons.sync_rounded),
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
iconSize: 20,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (newValue == true) {
|
||||
await _manageLinkedAlbums();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: albumSyncEnable ? 1.0 : 0.0,
|
||||
child: albumSyncEnable
|
||||
? SettingListTile(
|
||||
onTap: _manualSyncAlbums,
|
||||
contentPadding: const EdgeInsets.only(left: 32, right: 16),
|
||||
title: "organize_into_albums".t(context: context),
|
||||
subtitle: "organize_into_albums_description".t(context: context),
|
||||
trailing: isAlbumSyncInProgress
|
||||
? const SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: _manualSyncAlbums,
|
||||
icon: const Icon(Icons.sync_rounded),
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
iconSize: 20,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -164,60 +157,34 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsSwitchTile extends ConsumerStatefulWidget {
|
||||
final AppSettingsEnum<bool> appSettingsEnum;
|
||||
class _BackupSwitchTile extends ConsumerWidget {
|
||||
final MetadataKey<bool> metadataKey;
|
||||
final bool Function(AppConfig) selector;
|
||||
final String titleKey;
|
||||
final String subtitleKey;
|
||||
final void Function(bool?)? onChanged;
|
||||
final void Function(bool)? onChanged;
|
||||
|
||||
const _SettingsSwitchTile({
|
||||
required this.appSettingsEnum,
|
||||
const _BackupSwitchTile({
|
||||
required this.metadataKey,
|
||||
required this.selector,
|
||||
required this.titleKey,
|
||||
required this.subtitleKey,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SettingsSwitchTileState();
|
||||
}
|
||||
|
||||
class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
|
||||
late final Stream<bool?> valueStream;
|
||||
late final StreamSubscription<bool?> subscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
valueStream = Store.watch(widget.appSettingsEnum.storeKey).asBroadcastStream();
|
||||
subscription = valueStream.listen((value) {
|
||||
widget.onChanged?.call(value);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final value = ref.watch(appConfigProvider.select(selector));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: SettingListTile(
|
||||
title: widget.titleKey.t(context: context),
|
||||
subtitle: widget.subtitleKey.t(context: context),
|
||||
trailing: StreamBuilder(
|
||||
stream: valueStream,
|
||||
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
|
||||
builder: (context, snapshot) {
|
||||
final value = snapshot.data ?? false;
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: (bool newValue) async {
|
||||
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
|
||||
},
|
||||
);
|
||||
title: titleKey.t(context: context),
|
||||
subtitle: subtitleKey.t(context: context),
|
||||
trailing: Switch(
|
||||
value: value,
|
||||
onChanged: (bool newValue) async {
|
||||
await ref.read(metadataProvider).write(metadataKey, newValue);
|
||||
onChanged?.call(newValue);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -225,26 +192,28 @@ class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
|
||||
}
|
||||
}
|
||||
|
||||
class _UseWifiForUploadVideosButton extends ConsumerWidget {
|
||||
const _UseWifiForUploadVideosButton();
|
||||
class _UseCellularForVideosButton extends StatelessWidget {
|
||||
const _UseCellularForVideosButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const _SettingsSwitchTile(
|
||||
appSettingsEnum: AppSettingsEnum.useCellularForUploadVideos,
|
||||
Widget build(BuildContext context) {
|
||||
return _BackupSwitchTile(
|
||||
metadataKey: MetadataKey.backupUseCellularForVideos,
|
||||
selector: (c) => c.backup.useCellularForVideos,
|
||||
titleKey: "videos",
|
||||
subtitleKey: "network_requirement_videos_upload",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UseWifiForUploadPhotosButton extends ConsumerWidget {
|
||||
const _UseWifiForUploadPhotosButton();
|
||||
class _UseCellularForPhotosButton extends StatelessWidget {
|
||||
const _UseCellularForPhotosButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const _SettingsSwitchTile(
|
||||
appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos,
|
||||
Widget build(BuildContext context) {
|
||||
return _BackupSwitchTile(
|
||||
metadataKey: MetadataKey.backupUseCellularForPhotos,
|
||||
selector: (c) => c.backup.useCellularForPhotos,
|
||||
titleKey: "photos",
|
||||
subtitleKey: "network_requirement_photos_upload",
|
||||
);
|
||||
@@ -256,29 +225,22 @@ class _BackupOnlyWhenChargingButton extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return _SettingsSwitchTile(
|
||||
appSettingsEnum: AppSettingsEnum.backupRequireCharging,
|
||||
final fgService = ref.read(backgroundWorkerFgServiceProvider);
|
||||
return _BackupSwitchTile(
|
||||
metadataKey: MetadataKey.backupRequireCharging,
|
||||
selector: (c) => c.backup.requireCharging,
|
||||
titleKey: "charging",
|
||||
subtitleKey: "charging_requirement_mobile_backup",
|
||||
onChanged: (value) {
|
||||
ref.read(backgroundWorkerFgServiceProvider).configure(requireCharging: value ?? false);
|
||||
fgService.configure(requireCharging: value);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupDelaySlider extends ConsumerStatefulWidget {
|
||||
class _BackupDelaySlider extends ConsumerWidget {
|
||||
const _BackupDelaySlider();
|
||||
|
||||
@override
|
||||
ConsumerState<_BackupDelaySlider> createState() => _BackupDelaySliderState();
|
||||
}
|
||||
|
||||
class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
||||
late final Stream<int?> valueStream;
|
||||
late final StreamSubscription<int?> subscription;
|
||||
late int currentValue;
|
||||
|
||||
static int backupDelayToSliderValue(int ms) => switch (ms) {
|
||||
5 => 0,
|
||||
30 => 1,
|
||||
@@ -301,30 +263,9 @@ class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initialValue =
|
||||
Store.tryGet(AppSettingsEnum.backupTriggerDelay.storeKey) ?? AppSettingsEnum.backupTriggerDelay.defaultValue;
|
||||
currentValue = backupDelayToSliderValue(initialValue);
|
||||
|
||||
valueStream = Store.watch(AppSettingsEnum.backupTriggerDelay.storeKey).asBroadcastStream();
|
||||
subscription = valueStream.listen((value) {
|
||||
if (mounted && value != null) {
|
||||
setState(() {
|
||||
currentValue = backupDelayToSliderValue(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final triggerDelay = ref.watch(appConfigProvider.select((c) => c.backup.triggerDelay));
|
||||
final currentValue = backupDelayToSliderValue(triggerDelay);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -339,14 +280,13 @@ class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
|
||||
),
|
||||
Slider(
|
||||
value: currentValue.toDouble(),
|
||||
onChanged: (double v) {
|
||||
setState(() {
|
||||
currentValue = v.toInt();
|
||||
});
|
||||
onChanged: (double v) async {
|
||||
final seconds = backupDelayToSeconds(v.toInt());
|
||||
await ref.read(metadataProvider).write(MetadataKey.backupTriggerDelay, seconds);
|
||||
},
|
||||
onChangeEnd: (double v) async {
|
||||
final milliseconds = backupDelayToSeconds(v.toInt());
|
||||
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.backupTriggerDelay, milliseconds);
|
||||
final seconds = backupDelayToSeconds(v.toInt());
|
||||
await ref.read(metadataProvider).write(MetadataKey.backupTriggerDelay, seconds);
|
||||
},
|
||||
max: 3.0,
|
||||
min: 0.0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -354,8 +355,10 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
// To be removed once the experimental feature is stable
|
||||
if (CurrentPlatform.isAndroid &&
|
||||
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
|
||||
if ((kDebugMode || kProfileMode) &&
|
||||
CurrentPlatform.isAndroid &&
|
||||
(appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid) ||
|
||||
appSettingsService.getSetting<bool>(AppSettingsEnum.reviewOutOfSyncChangesAndroid))) ...[
|
||||
SettingGroupTitle(title: "trash".t(context: context)),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
|
||||
class SettingsRadioGroup<T> {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final T value;
|
||||
|
||||
const SettingsRadioGroup({required this.title, required this.value});
|
||||
const SettingsRadioGroup({required this.title, this.subtitle, required this.value});
|
||||
}
|
||||
|
||||
class SettingsRadioListTile<T> extends StatelessWidget {
|
||||
@@ -28,6 +30,12 @@ class SettingsRadioListTile<T> extends StatelessWidget {
|
||||
dense: true,
|
||||
activeColor: context.primaryColor,
|
||||
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||
subtitle: g.subtitle != null
|
||||
? Text(
|
||||
g.subtitle!,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
)
|
||||
: null,
|
||||
value: g.value,
|
||||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
),
|
||||
|
||||
@@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart';
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
enum PlatformAssetPlaybackStyle {
|
||||
unknown,
|
||||
image,
|
||||
video,
|
||||
imageAnimated,
|
||||
livePhoto,
|
||||
videoLooping,
|
||||
}
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
class PlatformAsset {
|
||||
final String id;
|
||||
@@ -142,6 +135,17 @@ abstract class NativeSyncApi {
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||
|
||||
bool hasManageMediaPermission();
|
||||
|
||||
@async
|
||||
bool requestManageMediaPermission();
|
||||
|
||||
@async
|
||||
bool manageMediaPermission();
|
||||
|
||||
@async
|
||||
bool restoreFromTrashById(String mediaId, int type);
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
@@ -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/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late LocalSyncService sut;
|
||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||
late LocalFilesManagerRepository mockLocalFilesManager;
|
||||
late StorageRepository mockStorageRepository;
|
||||
late MockDriftTrashSyncRepository mockDriftTrashSyncRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(<LocalAsset>[]);
|
||||
registerFallbackValue(<LocalAlbum>[]);
|
||||
registerFallbackValue(<String>[]);
|
||||
registerFallbackValue(<String, List<String>>{});
|
||||
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
@@ -48,11 +49,12 @@ void main() {
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
|
||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockDriftTrashSyncRepository = MockDriftTrashSyncRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
|
||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||
@@ -60,70 +62,58 @@ void main() {
|
||||
(_) async => SyncDelta(hasChanges: false, updates: const [], deletes: const [], assetAlbums: const {}),
|
||||
);
|
||||
when(() => mockNativeSyncApi.getTrashedAssets()).thenAnswer((_) async => {});
|
||||
when(() => mockNativeSyncApi.checkpointSync()).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
||||
when(() => mockDriftTrashSyncRepository.cleanup()).thenAnswer((_) async => 0);
|
||||
when(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).thenAnswer((_) async {});
|
||||
when(() => mockDriftTrashSyncRepository.recheckRemoteTrashCandidates()).thenAnswer((_) async {});
|
||||
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
localFilesManager: mockLocalFilesManager,
|
||||
storageRepository: mockStorageRepository,
|
||||
trashSyncRepository: mockDriftTrashSyncRepository,
|
||||
nativeSyncApi: mockNativeSyncApi,
|
||||
);
|
||||
|
||||
await Store.clear();
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
// After the refactor, LocalSyncService is just the OS-trash mirror
|
||||
// updater plus a delegating cleanup hook. The restore branch that
|
||||
// used to live here is now owned by DriftTrashSyncRepository (tested in
|
||||
// trash_sync_service_test.dart / trash_sync_repository_test.dart).
|
||||
group('LocalSyncService - OS trash mirror', () {
|
||||
test('updates mirror on Android regardless of store flags', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
await sut.sync();
|
||||
|
||||
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
test('invokes catch-up restore on Android', () async {
|
||||
// Regression: my refactor initially dropped this catch-up. The
|
||||
// original PR ran restore detection in `processTrashedAssets`
|
||||
// every sync. We preserve that periodic check via
|
||||
// DriftTrashSyncRepository.syncRestoresForRevivedAssets.
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
verify(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets()).called(1);
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
});
|
||||
|
||||
test('skips syncTrashedAssets on non-Android platforms', () async {
|
||||
test('skips mirror and catch-up on non-Android platforms', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||
verifyNever(() => mockDriftTrashSyncRepository.syncRestoresForRevivedAssets());
|
||||
});
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets behavior', () {
|
||||
test('processes trashed snapshot, restores assets, and trashes local files', () async {
|
||||
test('processTrashedAssets writes the OS mirror and no longer calls restore', () async {
|
||||
final platformAsset = PlatformAsset(
|
||||
id: 'remote-id',
|
||||
name: 'remote.jpg',
|
||||
@@ -131,29 +121,9 @@ void main() {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||
final restoredIds = ['image1'];
|
||||
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requested, orderedEquals(assetsToRestore));
|
||||
return restoredIds;
|
||||
});
|
||||
|
||||
final localAssetToTrash = LocalAssetStub.image2.copyWith(id: 'local-trash', checksum: 'checksum-trash');
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [localAssetToTrash],
|
||||
},
|
||||
);
|
||||
|
||||
final assetEntity = MockAssetEntity();
|
||||
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
||||
|
||||
await sut.processTrashedAssets({
|
||||
'album-a': [platformAsset],
|
||||
});
|
||||
@@ -166,41 +136,47 @@ void main() {
|
||||
expect(trashedEntry.albumId, 'album-a');
|
||||
expect(trashedEntry.asset.id, platformAsset.id);
|
||||
expect(trashedEntry.asset.name, platformAsset.name);
|
||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||
|
||||
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||
|
||||
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
||||
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['content://local-trash']);
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [localAssetToTrash]);
|
||||
});
|
||||
|
||||
test('does not attempt restore when repository has no assets to restore', () async {
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
|
||||
test('processTrashedAssets handles empty snapshot without errors', () async {
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
final trashedSnapshot =
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
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 {
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
test('cleans trash state after Android delta sync with changes', () async {
|
||||
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
|
||||
(_) async => SyncDelta(hasChanges: true, updates: const [], deletes: const [], assetAlbums: const {}),
|
||||
);
|
||||
when(() => mockNativeSyncApi.getAlbums()).thenAnswer((_) async => []);
|
||||
when(() => mockLocalAlbumRepository.updateAll(any())).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mockLocalAlbumRepository.processDelta(
|
||||
updates: any(named: 'updates'),
|
||||
deletes: any(named: 'deletes'),
|
||||
assetAlbums: any(named: 'assetAlbums'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
when(() => mockLocalAlbumRepository.getAll()).thenAnswer((_) async => []);
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
await sut.sync();
|
||||
|
||||
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||
verify(() => mockDriftTrashSyncRepository.cleanup()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,7 +191,7 @@ void main() {
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:mocktail/mocktail.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
|
||||
const _kAccessToken = '#ThisIsAToken';
|
||||
const _kEnableBackup = false;
|
||||
const _kAdvancedTroubleshooting = false;
|
||||
const _kVersion = 2;
|
||||
|
||||
void main() {
|
||||
@@ -22,13 +22,13 @@ void main() {
|
||||
mockDriftStoreRepo = MockDriftStoreRepository();
|
||||
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
|
||||
registerFallbackValue(StoreKey.accessToken);
|
||||
registerFallbackValue(StoreKey.backupTriggerDelay);
|
||||
registerFallbackValue(StoreKey.enableBackup);
|
||||
registerFallbackValue(StoreKey.version);
|
||||
registerFallbackValue(StoreKey.advancedTroubleshooting);
|
||||
|
||||
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
|
||||
(_) async => [
|
||||
const StoreDto(StoreKey.accessToken, _kAccessToken),
|
||||
const StoreDto(StoreKey.enableBackup, _kEnableBackup),
|
||||
const StoreDto(StoreKey.advancedTroubleshooting, _kAdvancedTroubleshooting),
|
||||
const StoreDto(StoreKey.version, _kVersion),
|
||||
],
|
||||
);
|
||||
@@ -46,7 +46,7 @@ void main() {
|
||||
test('Populates the internal cache on init', () {
|
||||
verify(() => mockDriftStoreRepo.getAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
|
||||
expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup);
|
||||
expect(sut.tryGet(StoreKey.advancedTroubleshooting), _kAdvancedTroubleshooting);
|
||||
expect(sut.tryGet(StoreKey.version), _kVersion);
|
||||
// Other keys should be null
|
||||
expect(sut.tryGet(StoreKey.currentUser), isNull);
|
||||
@@ -147,7 +147,7 @@ void main() {
|
||||
await sut.clear();
|
||||
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
|
||||
expect(sut.tryGet(StoreKey.accessToken), isNull);
|
||||
expect(sut.tryGet(StoreKey.enableBackup), isNull);
|
||||
expect(sut.tryGet(StoreKey.advancedTroubleshooting), isNull);
|
||||
expect(sut.tryGet(StoreKey.version), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,20 +4,16 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -26,8 +22,6 @@ import '../../api.mocks.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../fixtures/sync_stream.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
class _AbortCallbackWrapper {
|
||||
@@ -50,10 +44,7 @@ void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
late SyncApiRepository mockSyncApiRepo;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
||||
late StorageRepository mockStorageRepo;
|
||||
late DriftTrashSyncRepository mockDriftTrashSyncRepository;
|
||||
late MockApiService mockApi;
|
||||
late MockServerApi mockServerApi;
|
||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||
@@ -61,7 +52,6 @@ void main() {
|
||||
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
|
||||
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
|
||||
late Drift db;
|
||||
late bool hasManageMediaPermission;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -70,7 +60,7 @@ void main() {
|
||||
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
|
||||
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
@@ -84,10 +74,7 @@ void main() {
|
||||
setUp(() async {
|
||||
mockSyncStreamRepo = MockSyncStreamRepository();
|
||||
mockSyncApiRepo = MockSyncApiRepository();
|
||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
||||
mockStorageRepo = MockStorageRepository();
|
||||
mockDriftTrashSyncRepository = MockDriftTrashSyncRepository();
|
||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockApi = MockApiService();
|
||||
@@ -157,24 +144,15 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
trashSyncRepository: mockDriftTrashSyncRepository,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
hasManageMediaPermission = false;
|
||||
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
||||
when(() => mockDriftTrashSyncRepository.recordRemoteTrash(any())).thenAnswer((_) async {});
|
||||
when(() => mockDriftTrashSyncRepository.recordRemoteRestore(any())).thenAnswer((_) async {});
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
await Store.put(StoreKey.reviewOutOfSyncChangesAndroid, false);
|
||||
});
|
||||
|
||||
Future<void> simulateEvents(List<SyncEvent> events) async {
|
||||
@@ -239,10 +217,7 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
trashSyncRepository: mockDriftTrashSyncRepository,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -280,10 +255,7 @@ void main() {
|
||||
sut = SyncStreamService(
|
||||
syncApiRepository: mockSyncApiRepo,
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
trashSyncRepository: mockDriftTrashSyncRepository,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -396,128 +368,68 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group("SyncStreamService - remote trash & restore", () {
|
||||
setUp(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
hasManageMediaPermission = true;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
hasManageMediaPermission = false;
|
||||
});
|
||||
|
||||
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
|
||||
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
|
||||
final mergedAsset = LocalAssetStub.image2.copyWith(
|
||||
id: 'merged-local',
|
||||
checksum: 'checksum-merged',
|
||||
remoteId: 'remote-merged',
|
||||
);
|
||||
final assetsByAlbum = {
|
||||
'album-a': [localAsset],
|
||||
'album-b': [mergedAsset],
|
||||
};
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
final localEntity = MockAssetEntity();
|
||||
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
||||
|
||||
final mergedEntity = MockAssetEntity();
|
||||
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
|
||||
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
|
||||
final urls = invocation.positionalArguments.first as List<String>;
|
||||
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
|
||||
return true;
|
||||
});
|
||||
|
||||
group("SyncStreamService - delegates trash events to DriftTrashSyncRepository", () {
|
||||
test("assetV1 with deletedAt routes trash intents through recordRemoteTrash", () async {
|
||||
final trashedAt = DateTime(2025, 5, 1);
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-1',
|
||||
checksum: localAsset.checksum!,
|
||||
ack: 'asset-remote-local-1',
|
||||
trashedAt: DateTime(2025, 5, 1),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-2',
|
||||
checksum: mergedAsset.checksum!,
|
||||
ack: 'asset-remote-merged-2',
|
||||
trashedAt: DateTime(2025, 5, 2),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-3',
|
||||
checksum: 'checksum-remote-only',
|
||||
ack: 'asset-remote-only-3',
|
||||
trashedAt: DateTime(2025, 5, 3),
|
||||
checksum: 'checksum-1',
|
||||
ack: 'asset-trashed-1',
|
||||
trashedAt: trashedAt,
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
||||
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||
final captured =
|
||||
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 {
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-only',
|
||||
checksum: 'checksum-only',
|
||||
ack: 'asset-remote-only-9',
|
||||
trashedAt: DateTime(2025, 6, 1),
|
||||
),
|
||||
];
|
||||
test("assetV1 with null deletedAt routes alive checksums through recordRemoteRestore", () async {
|
||||
final events = [SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-1', ack: 'asset-restored-1')];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
final captured =
|
||||
verify(() => mockDriftTrashSyncRepository.recordRemoteRestore(captureAny())).captured.single
|
||||
as Iterable<String>;
|
||||
expect(captured.toList(), ['checksum-1']);
|
||||
});
|
||||
|
||||
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
|
||||
return {};
|
||||
});
|
||||
|
||||
test("assetDeleteV1 events route through recordRemoteTrash (permanent delete)", () async {
|
||||
final events = [SyncStreamStub.assetDeleteV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
verify(() => mockDriftTrashSyncRepository.recordRemoteTrash(any())).called(1);
|
||||
});
|
||||
|
||||
test("restores trashed local assets once the matching remote assets leave the trash", () async {
|
||||
final trashedAssets = [
|
||||
LocalAssetStub.image1.copyWith(id: 'trashed-1', checksum: 'checksum-trash', remoteId: 'remote-1'),
|
||||
];
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||
|
||||
final restoredIds = ['trashed-1'];
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||
return restoredIds;
|
||||
});
|
||||
|
||||
test("mixed batch routes trash and restore in one call each", () async {
|
||||
final trashedAt = DateTime(2025, 5, 1);
|
||||
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);
|
||||
|
||||
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
@@ -30,6 +30,7 @@ import 'schema_v23.dart' as v23;
|
||||
import 'schema_v24.dart' as v24;
|
||||
import 'schema_v25.dart' as v25;
|
||||
import 'schema_v26.dart' as v26;
|
||||
import 'schema_v27.dart' as v27;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -87,6 +88,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v25.DatabaseAtV25(db);
|
||||
case 26:
|
||||
return v26.DatabaseAtV26(db);
|
||||
case 27:
|
||||
return v27.DatabaseAtV27(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -119,5 +122,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
];
|
||||
}
|
||||
|
||||
+10412
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ import '../../fixtures/user.stub.dart';
|
||||
|
||||
const _kTestAccessToken = "#TestToken";
|
||||
const _kTestVersion = 10;
|
||||
const _kTestBackupRequireCharging = false;
|
||||
const _kTestAdvancedTroubleshooting = false;
|
||||
final _kTestUser = UserStub.admin;
|
||||
|
||||
Future<void> _populateStore(Drift db) async {
|
||||
@@ -21,8 +21,8 @@ Future<void> _populateStore(Drift db) async {
|
||||
batch.insert(
|
||||
db.storeEntity,
|
||||
StoreEntityCompanion(
|
||||
id: Value(StoreKey.backupRequireCharging.id),
|
||||
intValue: const Value(_kTestBackupRequireCharging ? 1 : 0),
|
||||
id: Value(StoreKey.advancedTroubleshooting.id),
|
||||
intValue: const Value(_kTestAdvancedTroubleshooting ? 1 : 0),
|
||||
stringValue: const Value(null),
|
||||
),
|
||||
);
|
||||
@@ -76,11 +76,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('converts bool', () async {
|
||||
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isNull);
|
||||
await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging);
|
||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, _kTestBackupRequireCharging);
|
||||
bool? advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||
expect(advancedTroubleshooting, isNull);
|
||||
await sut.upsert(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting);
|
||||
advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||
expect(advancedTroubleshooting, _kTestAdvancedTroubleshooting);
|
||||
});
|
||||
|
||||
test('converts user', () async {
|
||||
@@ -98,11 +98,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('delete()', () async {
|
||||
bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isFalse);
|
||||
await sut.delete(StoreKey.backupRequireCharging);
|
||||
backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging);
|
||||
expect(backupRequireCharging, isNull);
|
||||
bool? advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||
expect(advancedTroubleshooting, isFalse);
|
||||
await sut.delete(StoreKey.advancedTroubleshooting);
|
||||
advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
|
||||
expect(advancedTroubleshooting, isNull);
|
||||
});
|
||||
|
||||
test('deleteAll()', () async {
|
||||
@@ -147,13 +147,13 @@ void main() {
|
||||
emitsInOrder([
|
||||
[
|
||||
const StoreDto<Object>(StoreKey.version, _kTestVersion),
|
||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||
const StoreDto<Object>(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting),
|
||||
],
|
||||
[
|
||||
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
|
||||
const StoreDto<Object>(StoreKey.backupRequireCharging, _kTestBackupRequireCharging),
|
||||
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
|
||||
const StoreDto<Object>(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting),
|
||||
],
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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_migration.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
@@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
||||
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
|
||||
class MockDriftTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
@@ -19,6 +23,85 @@ void main() {
|
||||
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', () {
|
||||
final cutoffDate = DateTime(2024, 1, 1);
|
||||
final beforeCutoff = DateTime(2023, 12, 31);
|
||||
|
||||
@@ -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_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
@@ -14,6 +13,4 @@ class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
|
||||
|
||||
class MockTagService extends Mock implements TagService {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
@@ -24,7 +25,7 @@ void main() {
|
||||
late MockDriftLocalAssetRepository localAssetRepository;
|
||||
late MockDriftAlbumApiRepository albumApiRepository;
|
||||
late MockRemoteAlbumRepository remoteAlbumRepository;
|
||||
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
|
||||
late MockDriftTrashSyncRepository trashSyncRepository;
|
||||
late MockAssetMediaRepository assetMediaRepository;
|
||||
late MockDownloadRepository downloadRepository;
|
||||
late MockTagService tagService;
|
||||
@@ -32,6 +33,7 @@ void main() {
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
|
||||
@@ -51,7 +53,7 @@ void main() {
|
||||
localAssetRepository = MockDriftLocalAssetRepository();
|
||||
albumApiRepository = MockDriftAlbumApiRepository();
|
||||
remoteAlbumRepository = MockRemoteAlbumRepository();
|
||||
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
trashSyncRepository = MockDriftTrashSyncRepository();
|
||||
assetMediaRepository = MockAssetMediaRepository();
|
||||
downloadRepository = MockDownloadRepository();
|
||||
tagService = MockTagService();
|
||||
@@ -62,11 +64,14 @@ void main() {
|
||||
localAssetRepository,
|
||||
albumApiRepository,
|
||||
remoteAlbumRepository,
|
||||
trashedLocalAssetRepository,
|
||||
trashSyncRepository,
|
||||
assetMediaRepository,
|
||||
downloadRepository,
|
||||
tagService,
|
||||
);
|
||||
|
||||
when(() => trashSyncRepository.recordUserManualTrash(any())).thenAnswer((_) async {});
|
||||
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -74,34 +79,32 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
verify(() => trashSyncRepository.recordUserManualTrash(ids)).called(1);
|
||||
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);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => trashSyncRepository.recordUserManualTrash(any()));
|
||||
});
|
||||
|
||||
test('short-circuits when nothing was deleted', () async {
|
||||
@@ -114,8 +117,48 @@ void main() {
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => trashSyncRepository.recordUserManualTrash(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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ void main() {
|
||||
late MockApiService apiService;
|
||||
late MockNetworkService networkService;
|
||||
late MockBackgroundSyncManager backgroundSyncManager;
|
||||
late MockAppSettingService appSettingsService;
|
||||
late Drift db;
|
||||
|
||||
setUp(() async {
|
||||
@@ -30,15 +29,12 @@ void main() {
|
||||
apiService = MockApiService();
|
||||
networkService = MockNetworkService();
|
||||
backgroundSyncManager = MockBackgroundSyncManager();
|
||||
appSettingsService = MockAppSettingService();
|
||||
|
||||
sut = AuthService(
|
||||
authApiRepository,
|
||||
authRepository,
|
||||
apiService,
|
||||
networkService,
|
||||
backgroundSyncManager,
|
||||
appSettingsService,
|
||||
);
|
||||
|
||||
registerFallbackValue(Uri());
|
||||
|
||||
@@ -13,11 +13,9 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
@@ -29,13 +27,10 @@ void main() {
|
||||
late MockStorageRepository mockStorageRepository;
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockAppSettingsService mockAppSettingsService;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
registerFallbackValue(AppSettingsEnum.useCellularForUploadPhotos);
|
||||
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
@@ -54,18 +49,13 @@ void main() {
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockAppSettingsService = MockAppSettingsService();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
|
||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadVideos)).thenReturn(false);
|
||||
when(() => mockAppSettingsService.getSetting(AppSettingsEnum.useCellularForUploadPhotos)).thenReturn(false);
|
||||
|
||||
sut = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
|
||||
@@ -181,7 +171,6 @@ void main() {
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
@@ -232,7 +221,6 @@ void main() {
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutAndroid.dispose());
|
||||
@@ -273,7 +261,6 @@ void main() {
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
@@ -314,7 +301,6 @@ void main() {
|
||||
mockStorageRepository,
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAppSettingsService,
|
||||
mockAssetMediaRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
@@ -91,6 +91,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -122,7 +123,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.share.shouldShow(context), isTrue);
|
||||
@@ -138,7 +140,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.share.shouldShow(context), isTrue);
|
||||
@@ -157,7 +160,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
|
||||
@@ -174,7 +178,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
|
||||
@@ -191,7 +196,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
|
||||
@@ -210,7 +216,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.archive.shouldShow(context), isTrue);
|
||||
@@ -227,7 +234,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||
@@ -244,7 +252,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||
@@ -261,7 +270,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||
@@ -278,7 +288,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.archive.shouldShow(context), isFalse);
|
||||
@@ -297,7 +308,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
|
||||
@@ -314,7 +326,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
|
||||
@@ -331,7 +344,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
|
||||
@@ -350,7 +364,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.download.shouldShow(context), isTrue);
|
||||
@@ -367,7 +382,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.download.shouldShow(context), isFalse);
|
||||
@@ -384,7 +400,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.download.shouldShow(context), isFalse);
|
||||
@@ -403,7 +420,8 @@ void main() {
|
||||
isStacked: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue);
|
||||
@@ -420,7 +438,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
isStacked: false,
|
||||
advancedTroubleshooting: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse);
|
||||
@@ -439,7 +458,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.trash.shouldShow(context), isTrue);
|
||||
@@ -456,7 +476,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
||||
@@ -531,7 +552,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
|
||||
@@ -548,7 +570,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
|
||||
@@ -585,7 +608,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.delete.shouldShow(context), isTrue);
|
||||
@@ -604,7 +628,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
|
||||
@@ -623,7 +648,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
|
||||
@@ -640,7 +666,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
|
||||
@@ -656,7 +683,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
|
||||
@@ -675,7 +703,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.upload.shouldShow(context), isTrue);
|
||||
@@ -694,7 +723,8 @@ void main() {
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
|
||||
@@ -710,7 +740,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
|
||||
@@ -908,7 +939,8 @@ void main() {
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
|
||||
@@ -925,7 +957,8 @@ void main() {
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
|
||||
@@ -942,7 +975,8 @@ void main() {
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
|
||||
@@ -958,7 +992,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
|
||||
@@ -976,7 +1011,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: true,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue);
|
||||
@@ -992,7 +1028,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse);
|
||||
@@ -1012,6 +1049,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: true,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -1029,6 +1067,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -1046,6 +1085,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -1068,6 +1108,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
});
|
||||
@@ -1087,7 +1128,8 @@ void main() {
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
final widget = buttonType.buildButton(contextWithAlbum);
|
||||
expect(widget, isA<Widget>());
|
||||
@@ -1101,7 +1143,8 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
final widget = buttonType.buildButton(contextWithAlbum);
|
||||
expect(widget, isA<Widget>());
|
||||
@@ -1131,7 +1174,8 @@ void main() {
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: true,
|
||||
source: ActionSource.timeline,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
final widget = buttonType.buildButton(contextWithAlbum);
|
||||
expect(widget, isA<Widget>());
|
||||
@@ -1155,6 +1199,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -1176,6 +1221,7 @@ void main() {
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -1195,6 +1241,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -1215,6 +1262,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
@@ -1229,6 +1277,7 @@ void main() {
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
isWaitingForTrashApproval: false,
|
||||
source: ActionSource.timeline,
|
||||
);
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Album ID",
|
||||
"x-nestjs_zod-parent-metadata": {
|
||||
"description": "Activity search"
|
||||
},
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
@@ -25,9 +22,6 @@
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Asset ID (if activity is for an asset)",
|
||||
"x-nestjs_zod-parent-metadata": {
|
||||
"description": "Activity search"
|
||||
},
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
@@ -38,9 +32,6 @@
|
||||
"name": "level",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"x-nestjs_zod-parent-metadata": {
|
||||
"description": "Activity search"
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReactionLevel"
|
||||
}
|
||||
@@ -49,9 +40,6 @@
|
||||
"name": "type",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"x-nestjs_zod-parent-metadata": {
|
||||
"description": "Activity search"
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReactionType"
|
||||
}
|
||||
@@ -61,9 +49,6 @@
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by user ID",
|
||||
"x-nestjs_zod-parent-metadata": {
|
||||
"description": "Activity search"
|
||||
},
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
@@ -187,9 +172,6 @@
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Album ID",
|
||||
"x-nestjs_zod-parent-metadata": {
|
||||
"description": "Activity"
|
||||
},
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
@@ -201,9 +183,6 @@
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Asset ID (if activity is for an asset)",
|
||||
"x-nestjs_zod-parent-metadata": {
|
||||
"description": "Activity"
|
||||
},
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
|
||||
@@ -36,18 +36,16 @@ const ActivityStatisticsResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'ActivityStatisticsResponseDto' });
|
||||
|
||||
const ActivitySchema = z
|
||||
.object({
|
||||
albumId: z.uuidv4().describe('Album ID'),
|
||||
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
|
||||
})
|
||||
.describe('Activity');
|
||||
const ActivitySchema = z.object({
|
||||
albumId: z.uuidv4().describe('Album ID'),
|
||||
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
|
||||
});
|
||||
|
||||
const ActivitySearchSchema = ActivitySchema.extend({
|
||||
type: ReactionTypeSchema.optional(),
|
||||
level: ReactionLevelSchema.optional(),
|
||||
userId: z.uuidv4().optional().describe('Filter by user ID'),
|
||||
}).describe('Activity search');
|
||||
});
|
||||
|
||||
const ActivityCreateSchema = ActivitySchema.extend({
|
||||
type: ReactionTypeSchema,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const { trigger, selectedKey, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BasicModal title={$t('add_step')} {onClose} size="medium">
|
||||
<BasicModal title={$t('add_step')} {onClose}>
|
||||
{#await searchPluginMethods({ trigger })}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium">
|
||||
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium">
|
||||
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { workflowTemplates, type WorkflowTemplate } from '$lib/templates/workflow-templates';
|
||||
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: (template?: WorkflowTemplate) => void;
|
||||
};
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let selected = $state<WorkflowTemplate>();
|
||||
|
||||
const onSubmit = () => onClose(selected);
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('workflow_templates')} {onClose} {onSubmit} disabled={!selected} size="medium">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each workflowTemplates as template (template.id)}
|
||||
<ListButton selected={selected?.id === template.id} onclick={() => (selected = template)}>
|
||||
<div class="flex w-full items-center gap-3 text-start">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary"
|
||||
>
|
||||
<Icon icon={template.icon} size="18" />
|
||||
</div>
|
||||
<div class="min-w-0 grow">
|
||||
<Text fontWeight="medium">{template.name}</Text>
|
||||
<Text size="tiny" color="muted">{template.description}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</div>
|
||||
</FormModal>
|
||||
@@ -17,7 +17,7 @@
|
||||
const onSubmit = () => onClose(selected);
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pluginManager.triggers as item (item.trigger)}
|
||||
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
||||
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
type WorkflowUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiCodeJson, mdiDelete, mdiFileDocumentMultipleOutline, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
||||
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
@@ -34,25 +33,7 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
|
||||
}),
|
||||
};
|
||||
|
||||
const UseTemplate: ActionItem = {
|
||||
title: $t('use_template'),
|
||||
icon: mdiFileDocumentMultipleOutline,
|
||||
onAction: async () => {
|
||||
const template = await modalManager.show(WorkflowTemplatePicker, {});
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
await handleCreateWorkflow({
|
||||
trigger: template.trigger,
|
||||
steps: template.steps,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
enabled: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return { Create, UseTemplate };
|
||||
return { Create };
|
||||
};
|
||||
|
||||
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { WorkflowTrigger, type WorkflowStepDto } from '@immich/sdk';
|
||||
import { mdiAccountGroupOutline, mdiMonitorScreenshot } from '@mdi/js';
|
||||
|
||||
export type WorkflowTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
trigger: WorkflowTrigger;
|
||||
steps: WorkflowStepDto[];
|
||||
};
|
||||
|
||||
export const workflowTemplates: WorkflowTemplate[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Archive screenshots to album',
|
||||
description: 'Add uploads with "screenshot" in the filename to an album and archive them',
|
||||
icon: mdiMonitorScreenshot,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#assetFileFilter',
|
||||
config: {
|
||||
pattern: 'screenshot',
|
||||
matchType: 'contains',
|
||||
caseSensitive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetAddToAlbums',
|
||||
config: { albumIds: [] },
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetArchive',
|
||||
config: { inverse: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Add person to album',
|
||||
description: 'Add assets to an album when a specific person is recognized',
|
||||
icon: mdiAccountGroupOutline,
|
||||
trigger: WorkflowTrigger.PersonRecognized,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#filterPerson',
|
||||
config: { personIds: [], matchAny: true },
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetAddToAlbums',
|
||||
config: { albumIds: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -4,24 +4,26 @@
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
|
||||
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CodeBlock,
|
||||
Container,
|
||||
Icon,
|
||||
IconButton,
|
||||
MenuItemType,
|
||||
menuManager,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@immich/ui';
|
||||
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
|
||||
import { mdiClose, mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
@@ -44,6 +46,20 @@
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerLabel = (triggerType: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
AssetCreate: $t('asset_created'),
|
||||
PersonRecognized: $t('person_recognized'),
|
||||
};
|
||||
return labels[triggerType] || triggerType;
|
||||
};
|
||||
|
||||
const formatTimestamp = (createdAt: string) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(createdAt));
|
||||
|
||||
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
||||
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
||||
void menuManager.show({
|
||||
@@ -59,7 +75,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
|
||||
const { Create } = $derived(getWorkflowsActions($t));
|
||||
|
||||
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
|
||||
await goto(Route.viewWorkflow(response));
|
||||
@@ -76,7 +92,13 @@
|
||||
|
||||
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[UseTemplate, Create]} scrollbar={false}>
|
||||
{#snippet chipItem(title: string)}
|
||||
<span class="rounded-xl border border-gray-200/80 bg-light px-3 py-1.5 text-sm dark:border-gray-600">
|
||||
<span class="font-medium text-dark">{title}</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
|
||||
<section class="flex place-content-center sm:mx-4">
|
||||
<Container center size="large" class="pb-28">
|
||||
{#if workflows.length === 0}
|
||||
@@ -89,77 +111,92 @@
|
||||
class="mx-auto mt-10"
|
||||
/>
|
||||
{:else}
|
||||
<div class="my-6 flex flex-col gap-3">
|
||||
<div class="my-6 grid gap-6">
|
||||
{#each workflows as workflow (workflow.id)}
|
||||
<Card class="group shadow-none transition-colors hover:border-primary">
|
||||
<CardHeader>
|
||||
<a
|
||||
href={Route.viewWorkflow({ id: workflow.id })}
|
||||
class="flex items-center gap-4"
|
||||
class:opacity-55={!workflow.enabled}
|
||||
>
|
||||
<div
|
||||
class={`flex size-11 shrink-0 items-center justify-center rounded-xl ${
|
||||
workflow.enabled
|
||||
? 'bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={mdiFlashOutline} size="20" />
|
||||
<Card class="border border-light-200">
|
||||
<CardHeader
|
||||
class={`flex flex-row gap-4 px-8 py-6 sm:items-center sm:gap-6 ${
|
||||
workflow.enabled
|
||||
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
|
||||
: 'bg-neutral-50 dark:bg-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
|
||||
></span>
|
||||
<CardTitle>{workflow.name || $t('workflow')}</CardTitle>
|
||||
</div>
|
||||
{#if workflow.description}
|
||||
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<CardTitle class="truncate font-semibold text-dark group-hover:text-primary">
|
||||
{workflow.name || $t('workflow')}
|
||||
</CardTitle>
|
||||
|
||||
{#if !workflow.enabled}
|
||||
<Badge size="small" color="secondary">
|
||||
{$t('disabled')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if workflow.description}
|
||||
<CardDescription class="mt-0.5 truncate">
|
||||
{workflow.description}
|
||||
</CardDescription>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="hidden text-right sm:block">
|
||||
<Text size="tiny">{$t('created_at')}</Text>
|
||||
<Text size="small" fontWeight="medium">
|
||||
{formatTimestamp(workflow.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiDotsVertical}
|
||||
aria-label={$t('menu')}
|
||||
onclick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
showWorkflowMenu(event, workflow);
|
||||
}}
|
||||
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- Trigger Section -->
|
||||
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
|
||||
</div>
|
||||
{@render chipItem(getTriggerLabel(workflow.trigger))}
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if workflow.steps.length === 0}
|
||||
<span class="text-sm text-light-600">
|
||||
{$t('no_steps')}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each workflow.steps as step, i (i)}
|
||||
{@render chipItem(pluginManager.getMethodLabel(step.method))}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedIds.has(workflow.id)}
|
||||
{#await getWorkflowForShare({ id: workflow.id }) then result}
|
||||
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
|
||||
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
|
||||
<Button
|
||||
class="mt-2"
|
||||
leadingIcon={mdiClose}
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={() => toggleExpanded(workflow.id)}
|
||||
onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
|
||||
>
|
||||
{$t('close')}
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
{/await}
|
||||
{/if}
|
||||
</CardHeader>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user