feat: opt-in sync of deletes and restores from web to Android (beta timeline) (#20473)
* feature(mobile, beta, Android): handle remote asset trash/restore events and rescan media - Handle move to trash and restore from trash for remote assets on Android - Trigger MediaScannerConnection to rescan affected media files * feature(mobile, beta, Android): fix rescan * fix imports * fix checking conditions * refactor naming * fix line breaks * refactor code rollback changes in BackgroundServicePlugin * refactor code (use separate TrashService) * refactor code * parallelize restoreFromTrash calls with Future.wait format trash.provider.dart * try to re-format trash.provider.dart * re-format trash.provider.dart * rename TrashService to TrashSyncService to avoid duplicated names revert changes in original trash.provider.dart * refactor code (minor nitpicks) * process restoreFromTrash sequentially instead of Future.wait * group local assets by checksum before moving to trash delete LocalAssetEntity records when moved to trash refactor code * fix format * use checksum for asset restoration refactro code * fix format * sync trash only for backup-selected assets * feat(db): add local_trashed_asset table and integrate with restoration flow - Add new `local_trashed_asset` table to store metadata of trashed assets - Save trashed asset info into `local_trashed_asset` before deletion - Use `local_trashed_asset` as source for asset restoration - Implement file restoration by `mediaId` * resolve merge conflicts * fix index creating on migration * rework trashed assets handling - add new table trashed_local_asset - mirror trashed assets data in trashed_local_asset. - compute checksums for assets trashed out-of-app. - restore assets present in trashed_local_asset and non-trashed in remote_asset. - simplify moving-to-trash logic based on remote_asset events. * resolve merge conflicts use updated approach for calculating checksums * use CurrentPlatform instead _platform fix mocks * revert redundant changes * Include trashed items in getMediaChanges Process trashed items delta during incremental sync * fix merge conflicts * fix format * trashed_local_asset table mirror of local_asset table structure trashed_local_asset<->local_asset transfer data on move to trash or restore refactor code * refactor and format code * refactor TrashedAsset model fix missed data transfering * refactor code remove unused model * fix label * fix merge conflicts * optimize, refactor code remove redundant code and checking getTrashedAssetsForAlbum for iOS tests for hash trashed assets * format code * fix migration fix tests * fix generated file * reuse exist checksums on trash data update handle restoration errors fix import * format code * sync_stream.service depend on repos refactor assets restoration update dependencies in tests * remove trashed asset model remove trash_sync.service refactor DriftTrashedLocalAssetRepository, LocalSyncService * rework fetching trashed assets data on native side optimize handling trashed assets in local sync service refactor code * update NativeSyncApi on iOS side remove unused code * optimize sync trashed assets call in full sync mode refactor code * fix format * remove albumIds from getTrashedAssets params fix upsert in trashed local asset repo refactor code * fix getTrashedAssets params * fix(trash-sync): clean up NativeSyncApiImplBase and correct applyDelta * refactor(trash-sync): optimize performance and fix minor issues * refactor(trash-sync): add missed index * feat(trash-sync): remove sinceLastCheckpoint param from getTrashedAssets * fix(trash-sync): fix target table * fix(trash-sync): remove unused extension * fix(trash-sync): remove unused code * fix(trash-sync): refactor code * fix(trash-sync): reformat file * fix(trash_sync): refactor code * fix(trash_sync): improve moving to trash * refactor(trash_sync): integrate MANAGE_MEDIA permission request into login flow and advanced settings * refactor(trash_sync): add additional checking for experimental trash sync flag and MANAGE_MEDIA permission. * refactor(trash_sync): resolve merge conflicts * refactor(trash_sync): fix format * resolve merge conflicts add await for alert dialog add missed request * refactor(trash_sync): rework MANAGE_MEDIA info widget show rationale text in permission request alert dialog refactor setting getter * fix(trash_sync): restore missing text values * fix(trash_sync): format file * fix(trash_sync): check backup enabled and remove remote asset existence check * fix(trash_sync): remove checking backup enabled test(trash_sync): cover sync-stream trash/restore paths and dedupe mocks * test(trash_sync): cover trash/restore flows for local_sync_service * chore(e2e): restore test-assets submodule pointer --------- Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com> Co-authored-by: Alex <alex.tran1502@gmail.com>pull/23765/head
parent
7705c84b04
commit
493cde9d55
|
|
@ -475,6 +475,7 @@
|
||||||
"allow_edits": "Allow edits",
|
"allow_edits": "Allow edits",
|
||||||
"allow_public_user_to_download": "Allow public user to download",
|
"allow_public_user_to_download": "Allow public user to download",
|
||||||
"allow_public_user_to_upload": "Allow public user to upload",
|
"allow_public_user_to_upload": "Allow public user to upload",
|
||||||
|
"allowed": "Allowed",
|
||||||
"alt_text_qr_code": "QR code image",
|
"alt_text_qr_code": "QR code image",
|
||||||
"anti_clockwise": "Anti-clockwise",
|
"anti_clockwise": "Anti-clockwise",
|
||||||
"api_key": "API Key",
|
"api_key": "API Key",
|
||||||
|
|
@ -1314,6 +1315,10 @@
|
||||||
"main_menu": "Main menu",
|
"main_menu": "Main menu",
|
||||||
"make": "Make",
|
"make": "Make",
|
||||||
"manage_geolocation": "Manage location",
|
"manage_geolocation": "Manage location",
|
||||||
|
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
|
||||||
|
"manage_media_access_settings": "Open settings",
|
||||||
|
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
||||||
|
"manage_media_access_title": "Media Management Access",
|
||||||
"manage_shared_links": "Manage shared links",
|
"manage_shared_links": "Manage shared links",
|
||||||
"manage_sharing_with_partners": "Manage sharing with partners",
|
"manage_sharing_with_partners": "Manage sharing with partners",
|
||||||
"manage_the_app_settings": "Manage the app settings",
|
"manage_the_app_settings": "Manage the app settings",
|
||||||
|
|
@ -1438,6 +1443,7 @@
|
||||||
"no_results_description": "Try a synonym or more general keyword",
|
"no_results_description": "Try a synonym or more general keyword",
|
||||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||||
"no_uploads_in_progress": "No uploads in progress",
|
"no_uploads_in_progress": "No uploads in progress",
|
||||||
|
"not_allowed": "Not allowed",
|
||||||
"not_available": "N/A",
|
"not_available": "N/A",
|
||||||
"not_in_any_album": "Not in any album",
|
"not_in_any_album": "Not in any album",
|
||||||
"not_selected": "Not selected",
|
"not_selected": "Not selected",
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||||
val mediaUrls = call.argument<List<String>>("mediaUrls")
|
val mediaUrls = call.argument<List<String>>("mediaUrls")
|
||||||
if (mediaUrls != null) {
|
if (mediaUrls != null) {
|
||||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
moveToTrash(mediaUrls, result)
|
moveToTrash(mediaUrls, result)
|
||||||
} else {
|
} else {
|
||||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
}
|
}
|
||||||
|
|
@ -155,15 +155,23 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||||
"restoreFromTrash" -> {
|
"restoreFromTrash" -> {
|
||||||
val fileName = call.argument<String>("fileName")
|
val fileName = call.argument<String>("fileName")
|
||||||
val type = call.argument<Int>("type")
|
val type = call.argument<Int>("type")
|
||||||
|
val mediaId = call.argument<String>("mediaId")
|
||||||
if (fileName != null && type != null) {
|
if (fileName != null && type != null) {
|
||||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
restoreFromTrash(fileName, type, result)
|
restoreFromTrash(fileName, type, result)
|
||||||
} else {
|
} else {
|
||||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
}
|
}
|
||||||
} else {
|
} else
|
||||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
if (mediaId != null && type != null) {
|
||||||
}
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
|
restoreFromTrashById(mediaId, type, result)
|
||||||
|
} else {
|
||||||
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_PARAMS", "Required params are not specified.", null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"requestManageMediaPermission" -> {
|
"requestManageMediaPermission" -> {
|
||||||
|
|
@ -175,6 +183,17 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"hasManageMediaPermission" -> {
|
||||||
|
if (hasManageMediaPermission()) {
|
||||||
|
Log.i("Manage storage permission", "Permission already granted")
|
||||||
|
result.success(true)
|
||||||
|
} else {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"manageMediaPermission" -> requestManageMediaPermission(result)
|
||||||
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -224,25 +243,47 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
|
private fun restoreFromTrashById(mediaId: String, type: Int, result: Result) {
|
||||||
val activity = activityBinding?.activity
|
val id = mediaId.toLongOrNull()
|
||||||
val contentResolver = context?.contentResolver
|
if (id == null) {
|
||||||
if (activity == null || contentResolver == null) {
|
result.error("INVALID_ID", "The file id is not a valid number: $mediaId", null)
|
||||||
result.error("TrashError", "Activity or ContentResolver not available", null)
|
return
|
||||||
return
|
}
|
||||||
}
|
if (!isInTrash(id)) {
|
||||||
|
result.error("TrashNotFound", "Item with id=$id not found in trash", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
val uri = ContentUris.withAppendedId(contentUriForType(type), id)
|
||||||
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
|
|
||||||
pendingResult = result // Store for onActivityResult
|
try {
|
||||||
activity.startIntentSenderForResult(
|
Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)")
|
||||||
pendingIntent.intentSender,
|
restoreUris(listOf(uri), result)
|
||||||
trashRequestCode,
|
} catch (e: Exception) {
|
||||||
null, 0, 0, 0
|
Log.w(TAG, "restoreFromTrashById failed", e)
|
||||||
)
|
}
|
||||||
} catch (e: Exception) {
|
}
|
||||||
Log.e("TrashError", "Error creating or starting trash request", e)
|
|
||||||
result.error("TrashError", "Error creating or starting trash request", null)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
|
||||||
|
val activity = activityBinding?.activity
|
||||||
|
val contentResolver = context?.contentResolver
|
||||||
|
if (activity == null || contentResolver == null) {
|
||||||
|
result.error("TrashError", "Activity or ContentResolver not available", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
|
||||||
|
pendingResult = result // Store for onActivityResult
|
||||||
|
activity.startIntentSenderForResult(
|
||||||
|
pendingIntent.intentSender,
|
||||||
|
trashRequestCode,
|
||||||
|
null, 0, 0, 0
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("TrashError", "Error creating or starting trash request", e)
|
||||||
|
result.error("TrashError", "Error creating or starting trash request", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,14 +305,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||||
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
||||||
// same order as AssetType from dart
|
return ContentUris.withAppendedId(contentUriForType(type), id)
|
||||||
val contentUri = when (type) {
|
|
||||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
||||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
|
||||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
|
||||||
else -> queryUri
|
|
||||||
}
|
|
||||||
return ContentUris.withAppendedId(contentUri, id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
@ -315,6 +349,40 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun isInTrash(id: Long): Boolean {
|
||||||
|
val contentResolver = context?.contentResolver ?: return false
|
||||||
|
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 contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||||
|
?.use { it.moveToFirst() } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun restoreUris(uris: List<Uri>, result: Result) {
|
||||||
|
if (uris.isEmpty()) {
|
||||||
|
result.error("TrashError", "No URIs to restore", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}")
|
||||||
|
toggleTrash(uris, false, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "BackgroundServicePlugin"
|
private const val TAG = "BackgroundServicePlugin"
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,7 @@ interface NativeSyncApi {
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
|
|
@ -483,6 +484,21 @@ interface NativeSyncApi {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.getTrashedAssets())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
MessagesPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,9 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
|
||||||
override fun getMediaChanges(): SyncDelta {
|
override fun getMediaChanges(): SyncDelta {
|
||||||
throw IllegalStateException("Method not supported on this Android version.")
|
throw IllegalStateException("Method not supported on this Android version.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||||
|
//Method not supported on this Android version.
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package app.alextran.immich.sync
|
package app.alextran.immich.sync
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.RequiresExtension
|
import androidx.annotation.RequiresExtension
|
||||||
|
|
@ -86,4 +88,29 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
||||||
// Unmounted volumes are handled in dart when the album is removed
|
// Unmounted volumes are handled in dart when the album is removed
|
||||||
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
|
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||||
|
|
||||||
|
val result = LinkedHashMap<String, MutableList<PlatformAsset>>()
|
||||||
|
val volumes = MediaStore.getExternalVolumeNames(ctx)
|
||||||
|
|
||||||
|
for (volume in volumes) {
|
||||||
|
|
||||||
|
val queryArgs = Bundle().apply {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION)
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS)
|
||||||
|
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCursor(volume, queryArgs).use { cursor ->
|
||||||
|
getAssets(cursor).forEach { res ->
|
||||||
|
if (res is AssetResult.ValidAsset) {
|
||||||
|
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.mapValues { it.value.toList() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import android.annotation.SuppressLint
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
|
@ -81,6 +83,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||||
sortOrder,
|
sortOrder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
protected fun getCursor(
|
||||||
|
volume: String,
|
||||||
|
queryArgs: Bundle
|
||||||
|
): Cursor? = ctx.contentResolver.query(
|
||||||
|
MediaStore.Files.getContentUri(volume),
|
||||||
|
ASSET_PROJECTION,
|
||||||
|
queryArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
|
protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
|
||||||
return sequence {
|
return sequence {
|
||||||
cursor?.use { c ->
|
cursor?.use { c ->
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -364,6 +364,7 @@ protocol NativeSyncApi {
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
|
|
@ -532,5 +533,20 @@ class NativeSyncApiSetup {
|
||||||
} else {
|
} else {
|
||||||
cancelHashingChannel.setMessageHandler(nil)
|
cancelHashingChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let getTrashedAssetsChannel = taskQueue == nil
|
||||||
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
if let api = api {
|
||||||
|
getTrashedAssetsChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
let result = try api.getTrashedAssets()
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,15 @@ import CryptoKit
|
||||||
|
|
||||||
struct AssetWrapper: Hashable, Equatable {
|
struct AssetWrapper: Hashable, Equatable {
|
||||||
let asset: PlatformAsset
|
let asset: PlatformAsset
|
||||||
|
|
||||||
init(with asset: PlatformAsset) {
|
init(with asset: PlatformAsset) {
|
||||||
self.asset = asset
|
self.asset = asset
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(self.asset.id)
|
hasher.combine(self.asset.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
|
static func == (lhs: AssetWrapper, rhs: AssetWrapper) -> Bool {
|
||||||
return lhs.asset.id == rhs.asset.id
|
return lhs.asset.id == rhs.asset.id
|
||||||
}
|
}
|
||||||
|
|
@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable {
|
||||||
|
|
||||||
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
static let name = "NativeSyncApi"
|
static let name = "NativeSyncApi"
|
||||||
|
|
||||||
static func register(with registrar: any FlutterPluginRegistrar) {
|
static func register(with registrar: any FlutterPluginRegistrar) {
|
||||||
let instance = NativeSyncApiImpl()
|
let instance = NativeSyncApiImpl()
|
||||||
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
||||||
registrar.publish(instance)
|
registrar.publish(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
|
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
|
||||||
super.detachFromEngine()
|
super.detachFromEngine()
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
private let recoveredAlbumSubType = 1000000219
|
private let recoveredAlbumSubType = 1000000219
|
||||||
|
|
||||||
private var hashTask: Task<Void?, Error>?
|
private var hashTask: Task<Void?, Error>?
|
||||||
private static let hashCancelledCode = "HASH_CANCELLED"
|
private static let hashCancelledCode = "HASH_CANCELLED"
|
||||||
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
||||||
|
|
||||||
|
|
||||||
init(with defaults: UserDefaults = .standard) {
|
init(with defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16, *)
|
@available(iOS 16, *)
|
||||||
private func getChangeToken() -> PHPersistentChangeToken? {
|
private func getChangeToken() -> PHPersistentChangeToken? {
|
||||||
guard let data = defaults.data(forKey: changeTokenKey) else {
|
guard let data = defaults.data(forKey: changeTokenKey) else {
|
||||||
|
|
@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
}
|
}
|
||||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
|
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16, *)
|
@available(iOS 16, *)
|
||||||
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
|
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
|
||||||
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
|
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
|
||||||
|
|
@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
}
|
}
|
||||||
defaults.set(data, forKey: changeTokenKey)
|
defaults.set(data, forKey: changeTokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSyncCheckpoint() -> Void {
|
func clearSyncCheckpoint() -> Void {
|
||||||
defaults.removeObject(forKey: changeTokenKey)
|
defaults.removeObject(forKey: changeTokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkpointSync() {
|
func checkpointSync() {
|
||||||
guard #available(iOS 16, *) else {
|
guard #available(iOS 16, *) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
|
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldFullSync() -> Bool {
|
func shouldFullSync() -> Bool {
|
||||||
guard #available(iOS 16, *),
|
guard #available(iOS 16, *),
|
||||||
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
||||||
|
|
@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
|
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
|
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
|
||||||
// Cannot fetch persistent changes
|
// Cannot fetch persistent changes
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlbums() throws -> [PlatformAlbum] {
|
func getAlbums() throws -> [PlatformAlbum] {
|
||||||
var albums: [PlatformAlbum] = []
|
var albums: [PlatformAlbum] = []
|
||||||
|
|
||||||
albumTypes.forEach { type in
|
albumTypes.forEach { type in
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||||
for i in 0..<collections.count {
|
for i in 0..<collections.count {
|
||||||
let album = collections.object(at: i)
|
let album = collections.object(at: i)
|
||||||
|
|
||||||
// Ignore recovered album
|
// Ignore recovered album
|
||||||
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
|
|
||||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||||
|
|
||||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||||
|
|
||||||
var domainAlbum = PlatformAlbum(
|
var domainAlbum = PlatformAlbum(
|
||||||
id: album.localIdentifier,
|
id: album.localIdentifier,
|
||||||
name: album.localizedTitle!,
|
name: album.localizedTitle!,
|
||||||
|
|
@ -115,57 +115,57 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
isCloud: isCloud,
|
isCloud: isCloud,
|
||||||
assetCount: Int64(assets.count)
|
assetCount: Int64(assets.count)
|
||||||
)
|
)
|
||||||
|
|
||||||
if let firstAsset = assets.firstObject {
|
if let firstAsset = assets.firstObject {
|
||||||
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
|
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
|
||||||
}
|
}
|
||||||
|
|
||||||
albums.append(domainAlbum)
|
albums.append(domainAlbum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return albums.sorted { $0.id < $1.id }
|
return albums.sorted { $0.id < $1.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMediaChanges() throws -> SyncDelta {
|
func getMediaChanges() throws -> SyncDelta {
|
||||||
guard #available(iOS 16, *) else {
|
guard #available(iOS 16, *) else {
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
|
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
|
||||||
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
|
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let storedToken = getChangeToken() else {
|
guard let storedToken = getChangeToken() else {
|
||||||
// No token exists, definitely need a full sync
|
// No token exists, definitely need a full sync
|
||||||
print("MediaManager::getMediaChanges: No token found")
|
print("MediaManager::getMediaChanges: No token found")
|
||||||
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
|
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentToken = PHPhotoLibrary.shared().currentChangeToken
|
let currentToken = PHPhotoLibrary.shared().currentChangeToken
|
||||||
if storedToken == currentToken {
|
if storedToken == currentToken {
|
||||||
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
|
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
|
||||||
|
|
||||||
var updatedAssets: Set<AssetWrapper> = []
|
var updatedAssets: Set<AssetWrapper> = []
|
||||||
var deletedAssets: Set<String> = []
|
var deletedAssets: Set<String> = []
|
||||||
|
|
||||||
for change in changes {
|
for change in changes {
|
||||||
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
|
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
|
||||||
|
|
||||||
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
|
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
|
||||||
deletedAssets.formUnion(details.deletedLocalIdentifiers)
|
deletedAssets.formUnion(details.deletedLocalIdentifiers)
|
||||||
|
|
||||||
if (updated.isEmpty) { continue }
|
if (updated.isEmpty) { continue }
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
||||||
for i in 0..<result.count {
|
for i in 0..<result.count {
|
||||||
let asset = result.object(at: i)
|
let asset = result.object(at: i)
|
||||||
|
|
||||||
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
||||||
let predicate = PlatformAsset(
|
let predicate = PlatformAsset(
|
||||||
id: asset.localIdentifier,
|
id: asset.localIdentifier,
|
||||||
|
|
@ -178,25 +178,25 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
||||||
updatedAssets.insert(domainAsset)
|
updatedAssets.insert(domainAsset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let updates = Array(updatedAssets.map { $0.asset })
|
let updates = Array(updatedAssets.map { $0.asset })
|
||||||
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
|
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
|
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
|
||||||
guard !assets.isEmpty else {
|
guard !assets.isEmpty else {
|
||||||
return [:]
|
return [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
var albumAssets: [String: [String]] = [:]
|
var albumAssets: [String: [String]] = [:]
|
||||||
|
|
||||||
for type in albumTypes {
|
for type in albumTypes {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||||
collections.enumerateObjects { (album, _, _) in
|
collections.enumerateObjects { (album, _, _) in
|
||||||
|
|
@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
}
|
}
|
||||||
return albumAssets
|
return albumAssets
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids: [String] = []
|
var ids: [String] = []
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
|
|
@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
|
|
@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||||
return Int64(assets.count)
|
return Int64(assets.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
if(updatedTimeCond != nil) {
|
if(updatedTimeCond != nil) {
|
||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = getAssetsFromAlbum(in: album, options: options)
|
let result = getAssetsFromAlbum(in: album, options: options)
|
||||||
if(result.count == 0) {
|
if(result.count == 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
var assets: [PlatformAsset] = []
|
var assets: [PlatformAsset] = []
|
||||||
result.enumerateObjects { (asset, _, _) in
|
result.enumerateObjects { (asset, _, _) in
|
||||||
assets.append(asset.toPlatformAsset())
|
assets.append(asset.toPlatformAsset())
|
||||||
}
|
}
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
||||||
if let prevTask = hashTask {
|
if let prevTask = hashTask {
|
||||||
prevTask.cancel()
|
prevTask.cancel()
|
||||||
|
|
@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
missingAssetIds.remove(asset.localIdentifier)
|
missingAssetIds.remove(asset.localIdentifier)
|
||||||
assets.append(asset)
|
assets.append(asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||||
var results = [HashResult]()
|
var results = [HashResult]()
|
||||||
results.reserveCapacity(assets.count)
|
results.reserveCapacity(assets.count)
|
||||||
|
|
@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for await result in taskGroup {
|
for await result in taskGroup {
|
||||||
guard let result = result else {
|
guard let result = result else {
|
||||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||||
}
|
}
|
||||||
results.append(result)
|
results.append(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
for missing in missingAssetIds {
|
for missing in missingAssetIds {
|
||||||
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
return self?.completeWhenActive(for: completion, with: .success(results))
|
return self?.completeWhenActive(for: completion, with: .success(results))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelHashing() {
|
func cancelHashing() {
|
||||||
hashTask?.cancel()
|
hashTask?.cancel()
|
||||||
hashTask = nil
|
hashTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
||||||
class RequestRef {
|
class RequestRef {
|
||||||
var id: PHAssetResourceDataRequestID?
|
var id: PHAssetResourceDataRequestID?
|
||||||
|
|
@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let resource = asset.getResource() else {
|
guard let resource = asset.getResource() else {
|
||||||
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
|
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = PHAssetResourceRequestOptions()
|
let options = PHAssetResourceRequestOptions()
|
||||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
var hasher = Insecure.SHA1()
|
var hasher = Insecure.SHA1()
|
||||||
|
|
||||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||||
for: resource,
|
for: resource,
|
||||||
options: options,
|
options: options,
|
||||||
|
|
@ -377,7 +377,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||||
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||||
|
}
|
||||||
|
|
||||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||||
// Ensure to actually getting all assets for the Recents album
|
// Ensure to actually getting all assets for the Recents album
|
||||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||||
|
|
|
||||||
|
|
@ -58,3 +58,6 @@ const int kPhotoTabIndex = 0;
|
||||||
const int kSearchTabIndex = 1;
|
const int kSearchTabIndex = 1;
|
||||||
const int kAlbumTabIndex = 2;
|
const int kAlbumTabIndex = 2;
|
||||||
const int kLibraryTabIndex = 3;
|
const int kLibraryTabIndex = 3;
|
||||||
|
|
||||||
|
// Workaround for SQLite's variable limit (SQLITE_MAX_VARIABLE_NUMBER = 32766)
|
||||||
|
const int kDriftMaxChunk = 32000;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
|
@ -13,6 +15,7 @@ class HashService {
|
||||||
final int _batchSize;
|
final int _batchSize;
|
||||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
final _log = Logger('HashService');
|
final _log = Logger('HashService');
|
||||||
|
|
@ -20,11 +23,13 @@ class HashService {
|
||||||
HashService({
|
HashService({
|
||||||
required DriftLocalAlbumRepository localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
int? batchSize,
|
int? batchSize,
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_cancelChecker = cancelChecker,
|
_cancelChecker = cancelChecker,
|
||||||
_nativeSyncApi = nativeSyncApi,
|
_nativeSyncApi = nativeSyncApi,
|
||||||
_batchSize = batchSize ?? kBatchHashFileLimit;
|
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||||
|
|
@ -49,6 +54,14 @@ class HashService {
|
||||||
await _hashAssets(album, assetsToHash);
|
await _hashAssets(album, assetsToHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||||
|
final backupAlbumIds = localAlbums.map((e) => e.id);
|
||||||
|
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||||
|
if (trashedToHash.isNotEmpty) {
|
||||||
|
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||||
|
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
if (e.code == _kHashCancelledCode) {
|
if (e.code == _kHashCancelledCode) {
|
||||||
_log.warning("Hashing cancelled by platform");
|
_log.warning("Hashing cancelled by platform");
|
||||||
|
|
@ -65,7 +78,7 @@ class HashService {
|
||||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash) async {
|
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
|
||||||
final toHash = <String, LocalAsset>{};
|
final toHash = <String, LocalAsset>{};
|
||||||
|
|
||||||
for (final asset in assetsToHash) {
|
for (final asset in assetsToHash) {
|
||||||
|
|
@ -76,16 +89,16 @@ class HashService {
|
||||||
|
|
||||||
toHash[asset.id] = asset;
|
toHash[asset.id] = asset;
|
||||||
if (toHash.length == _batchSize) {
|
if (toHash.length == _batchSize) {
|
||||||
await _processBatch(album, toHash);
|
await _processBatch(album, toHash, isTrashed);
|
||||||
toHash.clear();
|
toHash.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _processBatch(album, toHash);
|
await _processBatch(album, toHash, isTrashed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes a batch of assets.
|
/// Processes a batch of assets.
|
||||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash) async {
|
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
|
||||||
if (toHash.isEmpty) {
|
if (toHash.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +133,10 @@ class HashService {
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
if (isTrashed) {
|
||||||
await _localAssetRepository.updateHashes(hashed);
|
await _trashedLocalAssetRepository.updateHashes(hashed);
|
||||||
|
} else {
|
||||||
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,14 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
@ -14,15 +19,34 @@ import 'package:logging/logging.dart';
|
||||||
class LocalSyncService {
|
class LocalSyncService {
|
||||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
|
final LocalFilesManagerRepository _localFilesManager;
|
||||||
|
final StorageRepository _storageRepository;
|
||||||
final Logger _log = Logger("DeviceSyncService");
|
final Logger _log = Logger("DeviceSyncService");
|
||||||
|
|
||||||
LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi})
|
LocalSyncService({
|
||||||
: _localAlbumRepository = localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
_nativeSyncApi = nativeSyncApi;
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
|
required LocalFilesManagerRepository localFilesManager,
|
||||||
|
required StorageRepository storageRepository,
|
||||||
|
required NativeSyncApi nativeSyncApi,
|
||||||
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
|
_localFilesManager = localFilesManager,
|
||||||
|
_storageRepository = storageRepository,
|
||||||
|
_nativeSyncApi = nativeSyncApi;
|
||||||
|
|
||||||
Future<void> sync({bool full = false}) async {
|
Future<void> sync({bool full = false}) async {
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
|
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||||
|
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||||
|
if (hasPermission) {
|
||||||
|
await _syncTrashedAssets();
|
||||||
|
} else {
|
||||||
|
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (full || await _nativeSyncApi.shouldFullSync()) {
|
if (full || await _nativeSyncApi.shouldFullSync()) {
|
||||||
_log.fine("Full sync request from ${full ? "user" : "native"}");
|
_log.fine("Full sync request from ${full ? "user" : "native"}");
|
||||||
return await fullSync();
|
return await fullSync();
|
||||||
|
|
@ -69,7 +93,6 @@ class LocalSyncService {
|
||||||
await updateAlbum(dbAlbum, album);
|
await updateAlbum(dbAlbum, album);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _nativeSyncApi.checkpointSync();
|
await _nativeSyncApi.checkpointSync();
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
_log.severe("Error performing device sync", e, s);
|
_log.severe("Error performing device sync", e, s);
|
||||||
|
|
@ -273,6 +296,48 @@ class LocalSyncService {
|
||||||
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
|
bool _albumsEqual(LocalAlbum a, LocalAlbum b) {
|
||||||
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
|
return a.name == b.name && a.assetCount == b.assetCount && a.updatedAt.isAtSameMomentAs(b.updatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _syncTrashedAssets() async {
|
||||||
|
final trashedAssetMap = await _nativeSyncApi.getTrashedAssets();
|
||||||
|
await processTrashedAssets(trashedAssetMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> processTrashedAssets(Map<String, List<PlatformAsset>> trashedAssetMap) async {
|
||||||
|
if (trashedAssetMap.isEmpty) {
|
||||||
|
_log.info("syncTrashedAssets, No trashed assets found");
|
||||||
|
}
|
||||||
|
final trashedAssets = trashedAssetMap.cast<String, List<Object?>>().entries.expand(
|
||||||
|
(entry) => entry.value.cast<PlatformAsset>().toTrashedAssets(entry.key),
|
||||||
|
);
|
||||||
|
|
||||||
|
_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on Iterable<PlatformAlbum> {
|
extension on Iterable<PlatformAlbum> {
|
||||||
|
|
@ -290,20 +355,26 @@ extension on Iterable<PlatformAlbum> {
|
||||||
|
|
||||||
extension on Iterable<PlatformAsset> {
|
extension on Iterable<PlatformAsset> {
|
||||||
List<LocalAsset> toLocalAssets() {
|
List<LocalAsset> toLocalAssets() {
|
||||||
return map(
|
return map((e) => e.toLocalAsset()).toList();
|
||||||
(e) => LocalAsset(
|
}
|
||||||
id: e.id,
|
|
||||||
name: e.name,
|
Iterable<TrashedAsset> toTrashedAssets(String albumId) {
|
||||||
checksum: null,
|
return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
|
||||||
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
|
||||||
createdAt: tryFromSecondsSinceEpoch(e.createdAt, isUtc: true) ?? DateTime.timestamp(),
|
|
||||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
|
||||||
width: e.width,
|
|
||||||
height: e.height,
|
|
||||||
durationInSeconds: e.durationInSeconds,
|
|
||||||
orientation: e.orientation,
|
|
||||||
isFavorite: e.isFavorite,
|
|
||||||
),
|
|
||||||
).toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension on PlatformAsset {
|
||||||
|
LocalAsset toLocalAsset() => LocalAsset(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
checksum: null,
|
||||||
|
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
|
||||||
|
createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||||
|
updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
durationInSeconds: durationInSeconds,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
orientation: orientation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
|
@ -11,14 +18,26 @@ class SyncStreamService {
|
||||||
|
|
||||||
final SyncApiRepository _syncApiRepository;
|
final SyncApiRepository _syncApiRepository;
|
||||||
final SyncStreamRepository _syncStreamRepository;
|
final SyncStreamRepository _syncStreamRepository;
|
||||||
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
|
final LocalFilesManagerRepository _localFilesManager;
|
||||||
|
final StorageRepository _storageRepository;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
|
|
||||||
SyncStreamService({
|
SyncStreamService({
|
||||||
required SyncApiRepository syncApiRepository,
|
required SyncApiRepository syncApiRepository,
|
||||||
required SyncStreamRepository syncStreamRepository,
|
required SyncStreamRepository syncStreamRepository,
|
||||||
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
|
required LocalFilesManagerRepository localFilesManager,
|
||||||
|
required StorageRepository storageRepository,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
}) : _syncApiRepository = syncApiRepository,
|
}) : _syncApiRepository = syncApiRepository,
|
||||||
_syncStreamRepository = syncStreamRepository,
|
_syncStreamRepository = syncStreamRepository,
|
||||||
|
_localAssetRepository = localAssetRepository,
|
||||||
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
|
_localFilesManager = localFilesManager,
|
||||||
|
_storageRepository = storageRepository,
|
||||||
_cancelChecker = cancelChecker;
|
_cancelChecker = cancelChecker;
|
||||||
|
|
||||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||||
|
|
@ -83,7 +102,18 @@ class SyncStreamService {
|
||||||
case SyncEntityType.partnerDeleteV1:
|
case SyncEntityType.partnerDeleteV1:
|
||||||
return _syncStreamRepository.deletePartnerV1(data.cast());
|
return _syncStreamRepository.deletePartnerV1(data.cast());
|
||||||
case SyncEntityType.assetV1:
|
case SyncEntityType.assetV1:
|
||||||
return _syncStreamRepository.updateAssetsV1(data.cast());
|
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||||
|
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||||
|
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||||
|
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||||
|
if (hasPermission) {
|
||||||
|
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
|
||||||
|
await _applyRemoteRestoreToLocal();
|
||||||
|
} else {
|
||||||
|
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
case SyncEntityType.assetDeleteV1:
|
case SyncEntityType.assetDeleteV1:
|
||||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||||
case SyncEntityType.assetExifV1:
|
case SyncEntityType.assetExifV1:
|
||||||
|
|
@ -212,4 +242,36 @@ class SyncStreamService {
|
||||||
_logger.severe("Error processing AssetUploadReadyV1 websocket batch events", error, stackTrace);
|
_logger.severe("Error processing AssetUploadReadyV1 websocket batch events", error, stackTrace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
|
||||||
|
if (checksums.isEmpty) {
|
||||||
|
return Future.value();
|
||||||
|
} else {
|
||||||
|
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
|
||||||
|
if (localAssetsToTrash.isNotEmpty) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.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';
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
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))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id, albumId};
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData {
|
||||||
|
LocalAsset toLocalAsset() => LocalAsset(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
checksum: checksum,
|
||||||
|
type: type,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
durationInSeconds: durationInSeconds,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
height: height,
|
||||||
|
width: width,
|
||||||
|
orientation: orientation,
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
|
||||||
|
|
@ -62,6 +63,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
|
||||||
PersonEntity,
|
PersonEntity,
|
||||||
AssetFaceEntity,
|
AssetFaceEntity,
|
||||||
StoreEntity,
|
StoreEntity,
|
||||||
|
TrashedLocalAssetEntity,
|
||||||
],
|
],
|
||||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||||
)
|
)
|
||||||
|
|
@ -93,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 12;
|
int get schemaVersion => 13;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
|
@ -178,6 +180,11 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
from12To13: (m, v13) async {
|
||||||
|
await m.create(v13.trashedLocalAssetEntity);
|
||||||
|
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
|
||||||
|
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
|
||||||
as i17;
|
as i17;
|
||||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
||||||
as i18;
|
as i18;
|
||||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
||||||
as i19;
|
as i19;
|
||||||
import 'package:drift/internal/modular.dart' as i20;
|
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||||
|
as i20;
|
||||||
|
import 'package:drift/internal/modular.dart' as i21;
|
||||||
|
|
||||||
abstract class $Drift extends i0.GeneratedDatabase {
|
abstract class $Drift extends i0.GeneratedDatabase {
|
||||||
$Drift(i0.QueryExecutor e) : super(e);
|
$Drift(i0.QueryExecutor e) : super(e);
|
||||||
|
|
@ -77,9 +79,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||||
late final i17.$AssetFaceEntityTable assetFaceEntity = i17
|
late final i17.$AssetFaceEntityTable assetFaceEntity = i17
|
||||||
.$AssetFaceEntityTable(this);
|
.$AssetFaceEntityTable(this);
|
||||||
late final i18.$StoreEntityTable storeEntity = i18.$StoreEntityTable(this);
|
late final i18.$StoreEntityTable storeEntity = i18.$StoreEntityTable(this);
|
||||||
i19.MergedAssetDrift get mergedAssetDrift => i20.ReadDatabaseContainer(
|
late final i19.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i19
|
||||||
|
.$TrashedLocalAssetEntityTable(this);
|
||||||
|
i20.MergedAssetDrift get mergedAssetDrift => i21.ReadDatabaseContainer(
|
||||||
this,
|
this,
|
||||||
).accessor<i19.MergedAssetDrift>(i19.MergedAssetDrift.new);
|
).accessor<i20.MergedAssetDrift>(i20.MergedAssetDrift.new);
|
||||||
@override
|
@override
|
||||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||||
|
|
@ -108,7 +112,10 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||||
personEntity,
|
personEntity,
|
||||||
assetFaceEntity,
|
assetFaceEntity,
|
||||||
storeEntity,
|
storeEntity,
|
||||||
|
trashedLocalAssetEntity,
|
||||||
i11.idxLatLng,
|
i11.idxLatLng,
|
||||||
|
i19.idxTrashedLocalAssetChecksum,
|
||||||
|
i19.idxTrashedLocalAssetAlbum,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
i0.StreamQueryUpdateRules
|
i0.StreamQueryUpdateRules
|
||||||
|
|
@ -336,4 +343,9 @@ class $DriftManager {
|
||||||
i17.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
|
i17.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
|
||||||
i18.$$StoreEntityTableTableManager get storeEntity =>
|
i18.$$StoreEntityTableTableManager get storeEntity =>
|
||||||
i18.$$StoreEntityTableTableManager(_db, _db.storeEntity);
|
i18.$$StoreEntityTableTableManager(_db, _db.storeEntity);
|
||||||
|
i19.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
|
||||||
|
i19.$$TrashedLocalAssetEntityTableTableManager(
|
||||||
|
_db,
|
||||||
|
_db.trashedLocalAssetEntity,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5037,6 +5037,454 @@ final class Schema12 extends i0.VersionedSchema {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class Schema13 extends i0.VersionedSchema {
|
||||||
|
Schema13({required super.database}) : super(version: 13);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
userEntity,
|
||||||
|
remoteAssetEntity,
|
||||||
|
stackEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
remoteAlbumEntity,
|
||||||
|
localAlbumEntity,
|
||||||
|
localAlbumAssetEntity,
|
||||||
|
idxLocalAssetChecksum,
|
||||||
|
idxRemoteAssetOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
idxRemoteAssetChecksum,
|
||||||
|
authUserEntity,
|
||||||
|
userMetadataEntity,
|
||||||
|
partnerEntity,
|
||||||
|
remoteExifEntity,
|
||||||
|
remoteAlbumAssetEntity,
|
||||||
|
remoteAlbumUserEntity,
|
||||||
|
memoryEntity,
|
||||||
|
memoryAssetEntity,
|
||||||
|
personEntity,
|
||||||
|
assetFaceEntity,
|
||||||
|
storeEntity,
|
||||||
|
trashedLocalAssetEntity,
|
||||||
|
idxLatLng,
|
||||||
|
idxTrashedLocalAssetChecksum,
|
||||||
|
idxTrashedLocalAssetAlbum,
|
||||||
|
];
|
||||||
|
late final Shape20 userEntity = Shape20(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_91,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape17 remoteAssetEntity = Shape17(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_13,
|
||||||
|
_column_14,
|
||||||
|
_column_15,
|
||||||
|
_column_16,
|
||||||
|
_column_17,
|
||||||
|
_column_18,
|
||||||
|
_column_19,
|
||||||
|
_column_20,
|
||||||
|
_column_21,
|
||||||
|
_column_86,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape3 stackEntity = Shape3(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'stack_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape2 localAssetEntity = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_22,
|
||||||
|
_column_14,
|
||||||
|
_column_23,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape9 remoteAlbumEntity = Shape9(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_56,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_57,
|
||||||
|
_column_58,
|
||||||
|
_column_59,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape19 localAlbumEntity = Shape19(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_5,
|
||||||
|
_column_31,
|
||||||
|
_column_32,
|
||||||
|
_column_90,
|
||||||
|
_column_33,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_34, _column_35, _column_33],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
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 idxRemoteAssetOwnerChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_owner_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||||
|
);
|
||||||
|
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)',
|
||||||
|
);
|
||||||
|
late final Shape21 authUserEntity = Shape21(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'auth_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_2,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_92,
|
||||||
|
_column_93,
|
||||||
|
_column_7,
|
||||||
|
_column_94,
|
||||||
|
],
|
||||||
|
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_25, _column_26, _column_27],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape5 partnerEntity = Shape5(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'partner_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||||
|
columns: [_column_28, _column_29, _column_30],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape8 remoteExifEntity = Shape8(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_exif_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_36,
|
||||||
|
_column_37,
|
||||||
|
_column_38,
|
||||||
|
_column_39,
|
||||||
|
_column_40,
|
||||||
|
_column_41,
|
||||||
|
_column_11,
|
||||||
|
_column_10,
|
||||||
|
_column_42,
|
||||||
|
_column_43,
|
||||||
|
_column_44,
|
||||||
|
_column_45,
|
||||||
|
_column_46,
|
||||||
|
_column_47,
|
||||||
|
_column_48,
|
||||||
|
_column_49,
|
||||||
|
_column_50,
|
||||||
|
_column_51,
|
||||||
|
_column_52,
|
||||||
|
_column_53,
|
||||||
|
_column_54,
|
||||||
|
_column_55,
|
||||||
|
],
|
||||||
|
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_36, _column_60],
|
||||||
|
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_60, _column_25, _column_61],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape11 memoryEntity = Shape11(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_18,
|
||||||
|
_column_15,
|
||||||
|
_column_8,
|
||||||
|
_column_62,
|
||||||
|
_column_63,
|
||||||
|
_column_64,
|
||||||
|
_column_65,
|
||||||
|
_column_66,
|
||||||
|
_column_67,
|
||||||
|
],
|
||||||
|
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_36, _column_68],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape14 personEntity = Shape14(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'person_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_1,
|
||||||
|
_column_69,
|
||||||
|
_column_71,
|
||||||
|
_column_72,
|
||||||
|
_column_73,
|
||||||
|
_column_74,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape15 assetFaceEntity = Shape15(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_face_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_36,
|
||||||
|
_column_76,
|
||||||
|
_column_77,
|
||||||
|
_column_78,
|
||||||
|
_column_79,
|
||||||
|
_column_80,
|
||||||
|
_column_81,
|
||||||
|
_column_82,
|
||||||
|
_column_83,
|
||||||
|
],
|
||||||
|
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_87, _column_88, _column_89],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape23 trashedLocalAssetEntity = Shape23(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'trashed_local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_95,
|
||||||
|
_column_22,
|
||||||
|
_column_14,
|
||||||
|
_column_23,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
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 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)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape23 extends i0.VersionedTable {
|
||||||
|
Shape23({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get name =>
|
||||||
|
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get type =>
|
||||||
|
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||||
|
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
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 durationInSeconds =>
|
||||||
|
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get albumId =>
|
||||||
|
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get checksum =>
|
||||||
|
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<bool> get isFavorite =>
|
||||||
|
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||||
|
i1.GeneratedColumn<int> get orientation =>
|
||||||
|
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_95(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'album_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
);
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
|
|
@ -5049,6 +5497,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
|
|
@ -5107,6 +5556,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from11To12(migrator, schema);
|
await from11To12(migrator, schema);
|
||||||
return 12;
|
return 12;
|
||||||
|
case 12:
|
||||||
|
final schema = Schema13(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from12To13(migrator, schema);
|
||||||
|
return 13;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
|
|
@ -5125,6 +5579,7 @@ i1.OnUpgrade stepByStep({
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
|
|
@ -5138,5 +5593,6 @@ i1.OnUpgrade stepByStep({
|
||||||
from9To10: from9To10,
|
from9To10: from9To10,
|
||||||
from10To11: from10To11,
|
from10To11: from10To11,
|
||||||
from11To12: from11To12,
|
from11To12: from11To12,
|
||||||
|
from12To13: from12To13,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:drift/drift.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/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||||
|
|
@ -8,6 +10,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
|
|
||||||
const DriftLocalAssetRepository(this._db) : super(_db);
|
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
|
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
|
||||||
|
|
@ -95,4 +98,32 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
}
|
}
|
||||||
return query.map((localAlbum) => localAlbum.toDto()).get();
|
return query.map((localAlbum) => localAlbum.toDto()).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
|
||||||
|
if (checksums.isEmpty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, List<LocalAsset>>{};
|
||||||
|
|
||||||
|
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
|
||||||
|
final rows =
|
||||||
|
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||||
|
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
|
||||||
|
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||||
|
])..where(
|
||||||
|
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||||
|
_db.localAssetEntity.checksum.isIn(slice),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
for (final row in rows) {
|
||||||
|
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
||||||
|
final assetData = row.readTable(_db.localAssetEntity);
|
||||||
|
final asset = assetData.toDto();
|
||||||
|
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
|
|
||||||
const RemoteAssetRepository(this._db) : super(_db);
|
const RemoteAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
/// For testing purposes
|
/// For testing purposes
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
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/infrastructure/entities/local_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
|
typedef TrashedAsset = ({String albumId, LocalAsset asset});
|
||||||
|
|
||||||
|
class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
|
final Drift _db;
|
||||||
|
|
||||||
|
const DriftTrashedLocalAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
Future<void> updateHashes(Map<String, String> hashes) {
|
||||||
|
if (hashes.isEmpty) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
return _db.batch((batch) async {
|
||||||
|
for (final entry in hashes.entries) {
|
||||||
|
batch.update(
|
||||||
|
_db.trashedLocalAssetEntity,
|
||||||
|
TrashedLocalAssetEntityCompanion(checksum: Value(entry.value)),
|
||||||
|
where: (e) => e.id.equals(entry.key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<LocalAsset>> getAssetsToHash(Iterable<String> albumIds) {
|
||||||
|
final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull());
|
||||||
|
return query.map((row) => row.toLocalAsset()).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Iterable<LocalAsset>> getToRestore() async {
|
||||||
|
final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity)
|
||||||
|
..addColumns([_db.localAlbumEntity.id])
|
||||||
|
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected)));
|
||||||
|
|
||||||
|
final rows =
|
||||||
|
await (_db.select(_db.trashedLocalAssetEntity).join([
|
||||||
|
innerJoin(
|
||||||
|
_db.remoteAssetEntity,
|
||||||
|
_db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum),
|
||||||
|
),
|
||||||
|
])..where(
|
||||||
|
_db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) &
|
||||||
|
_db.remoteAssetEntity.deletedAt.isNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
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();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final assetIds = trashedAssets.map((e) => e.asset.id).toSet();
|
||||||
|
Map<String, String> localChecksumById = await _getCachedChecksums(assetIds);
|
||||||
|
|
||||||
|
return _db.transaction(() async {
|
||||||
|
await _db.batch((batch) {
|
||||||
|
for (final item in trashedAssets) {
|
||||||
|
final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum;
|
||||||
|
final companion = TrashedLocalAssetEntityCompanion.insert(
|
||||||
|
id: item.asset.id,
|
||||||
|
albumId: item.albumId,
|
||||||
|
checksum: Value(effectiveChecksum),
|
||||||
|
name: item.asset.name,
|
||||||
|
type: item.asset.type,
|
||||||
|
createdAt: Value(item.asset.createdAt),
|
||||||
|
updatedAt: Value(item.asset.updatedAt),
|
||||||
|
width: Value(item.asset.width),
|
||||||
|
height: Value(item.asset.height),
|
||||||
|
durationInSeconds: Value(item.asset.durationInSeconds),
|
||||||
|
isFavorite: Value(item.asset.isFavorite),
|
||||||
|
orientation: Value(item.asset.orientation),
|
||||||
|
);
|
||||||
|
|
||||||
|
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
|
||||||
|
_db.trashedLocalAssetEntity,
|
||||||
|
companion,
|
||||||
|
onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (assetIds.length <= kDriftMaxChunk) {
|
||||||
|
await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(assetIds))).go();
|
||||||
|
} else {
|
||||||
|
final existingIds = await (_db.selectOnly(
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<int> watchCount() {
|
||||||
|
return (_db.selectOnly(_db.trashedLocalAssetEntity)..addColumns([_db.trashedLocalAssetEntity.id.count()]))
|
||||||
|
.watchSingle()
|
||||||
|
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<int> watchHashedCount() {
|
||||||
|
return (_db.selectOnly(_db.trashedLocalAssetEntity)
|
||||||
|
..addColumns([_db.trashedLocalAssetEntity.id.count()])
|
||||||
|
..where(_db.trashedLocalAssetEntity.checksum.isNotNull()))
|
||||||
|
.watchSingle()
|
||||||
|
.map((row) => row.read<int>(_db.trashedLocalAssetEntity.id.count()) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
|
||||||
|
if (assetsByAlbums.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final companions = <TrashedLocalAssetEntityCompanion>[];
|
||||||
|
final idToDelete = <String>{};
|
||||||
|
|
||||||
|
for (final entry in assetsByAlbums.entries) {
|
||||||
|
for (final asset in entry.value) {
|
||||||
|
idToDelete.add(asset.id);
|
||||||
|
companions.add(
|
||||||
|
TrashedLocalAssetEntityCompanion(
|
||||||
|
id: Value(asset.id),
|
||||||
|
name: Value(asset.name),
|
||||||
|
albumId: Value(entry.key),
|
||||||
|
checksum: Value(asset.checksum),
|
||||||
|
type: Value(asset.type),
|
||||||
|
width: Value(asset.width),
|
||||||
|
height: Value(asset.height),
|
||||||
|
durationInSeconds: Value(asset.durationInSeconds),
|
||||||
|
isFavorite: Value(asset.isFavorite),
|
||||||
|
orientation: Value(asset.orientation),
|
||||||
|
createdAt: Value(asset.createdAt),
|
||||||
|
updatedAt: Value(asset.updatedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.transaction(() async {
|
||||||
|
for (final companion in companions) {
|
||||||
|
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final slice in idToDelete.slices(kDriftMaxChunk)) {
|
||||||
|
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyRestoredAssets(List<String> idList) async {
|
||||||
|
if (idList.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trashedAssets = <TrashedLocalAssetEntityData>[];
|
||||||
|
|
||||||
|
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||||
|
final q = _db.select(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice));
|
||||||
|
trashedAssets.addAll(await q.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trashedAssets.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final companions = trashedAssets.map((e) {
|
||||||
|
return LocalAssetEntityCompanion.insert(
|
||||||
|
id: e.id,
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
createdAt: Value(e.createdAt),
|
||||||
|
updatedAt: Value(e.updatedAt),
|
||||||
|
width: Value(e.width),
|
||||||
|
height: Value(e.height),
|
||||||
|
durationInSeconds: Value(e.durationInSeconds),
|
||||||
|
checksum: Value(e.checksum),
|
||||||
|
isFavorite: Value(e.isFavorite),
|
||||||
|
orientation: Value(e.orientation),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.transaction(() async {
|
||||||
|
for (final companion in companions) {
|
||||||
|
await _db.into(_db.localAssetEntity).insertOnConflictUpdate(companion);
|
||||||
|
}
|
||||||
|
for (final slice in idList.slices(kDriftMaxChunk)) {
|
||||||
|
await (_db.delete(_db.trashedLocalAssetEntity)..where((t) => t.id.isIn(slice))).go();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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>{};
|
||||||
|
|
||||||
|
for (final slice in assetIds.slices(kDriftMaxChunk)) {
|
||||||
|
final rows =
|
||||||
|
await (_db.selectOnly(_db.localAssetEntity)
|
||||||
|
..where(_db.localAssetEntity.id.isIn(slice) & _db.localAssetEntity.checksum.isNotNull())
|
||||||
|
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
for (final r in rows) {
|
||||||
|
localChecksumById[r.read(_db.localAssetEntity.id)!] = r.read(_db.localAssetEntity.checksum)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return localChecksumById;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -562,4 +562,32 @@ class NativeSyncApi {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, List<PlatformAsset>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
|
|
@ -13,6 +14,10 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
||||||
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||||
|
(ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
final assetServiceProvider = Provider(
|
final assetServiceProvider = Provider(
|
||||||
(ref) => AssetService(
|
(ref) => AssetService(
|
||||||
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,17 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
|
|
||||||
final syncStreamServiceProvider = Provider(
|
final syncStreamServiceProvider = Provider(
|
||||||
(ref) => SyncStreamService(
|
(ref) => SyncStreamService(
|
||||||
syncApiRepository: ref.watch(syncApiRepositoryProvider),
|
syncApiRepository: ref.watch(syncApiRepositoryProvider),
|
||||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||||
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
|
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||||
|
storageRepository: ref.watch(storageRepositoryProvider),
|
||||||
cancelChecker: ref.watch(cancellationProvider),
|
cancelChecker: ref.watch(cancellationProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -26,6 +32,9 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.
|
||||||
final localSyncServiceProvider = Provider(
|
final localSyncServiceProvider = Provider(
|
||||||
(ref) => LocalSyncService(
|
(ref) => LocalSyncService(
|
||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
|
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||||
|
storageRepository: ref.watch(storageRepositoryProvider),
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -35,5 +44,6 @@ final hashServiceProvider = Provider(
|
||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import 'package:async/async.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
|
||||||
|
typedef TrashedAssetsCount = ({int total, int hashed});
|
||||||
|
|
||||||
|
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]));
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/services/trash.service.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/services/trash.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class TrashNotifier extends StateNotifier<bool> {
|
class TrashNotifier extends StateNotifier<bool> {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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:immich_mobile/services/local_files_manager.service.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
final localFilesManagerRepositoryProvider = Provider(
|
final localFilesManagerRepositoryProvider = Provider(
|
||||||
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
class LocalFilesManagerRepository {
|
class LocalFilesManagerRepository {
|
||||||
const LocalFilesManagerRepository(this._service);
|
LocalFilesManagerRepository(this._service);
|
||||||
|
|
||||||
|
final Logger _logger = Logger('SyncStreamService');
|
||||||
final LocalFilesManagerService _service;
|
final LocalFilesManagerService _service;
|
||||||
|
|
||||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||||
|
|
@ -21,4 +24,26 @@ class LocalFilesManagerRepository {
|
||||||
Future<bool> requestManageMediaPermission() async {
|
Future<bool> requestManageMediaPermission() async {
|
||||||
return await _service.requestManageMediaPermission();
|
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 {
|
||||||
|
await _service.restoreFromTrashById(asset.id, asset.type.index);
|
||||||
|
restoredIds.add(asset.id);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning("Restoring failure: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return restoredIds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref)
|
||||||
|
|
||||||
class LocalFilesManagerService {
|
class LocalFilesManagerService {
|
||||||
const LocalFilesManagerService();
|
const LocalFilesManagerService();
|
||||||
|
|
||||||
static final Logger _logger = Logger('LocalFilesManager');
|
static final Logger _logger = Logger('LocalFilesManager');
|
||||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||||
|
|
||||||
|
|
@ -27,6 +28,15 @@ class LocalFilesManagerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<bool> requestManageMediaPermission() async {
|
||||||
try {
|
try {
|
||||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||||
|
|
@ -35,4 +45,22 @@ class LocalFilesManagerService {
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
|
|
@ -177,6 +178,55 @@ class LoginForm extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getManageMediaPermission() async {
|
||||||
|
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||||
|
elevation: 5,
|
||||||
|
title: Text(
|
||||||
|
'manage_media_access_title',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
|
||||||
|
).tr(),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: ListBody(
|
||||||
|
children: [
|
||||||
|
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(
|
||||||
|
'cancel'.tr(),
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'manage_media_access_settings'.tr(),
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
|
||||||
|
|
||||||
login() async {
|
login() async {
|
||||||
TextInput.finishAutofillContext();
|
TextInput.finishAutofillContext();
|
||||||
|
|
||||||
|
|
@ -194,6 +244,9 @@ class LoginForm extends HookConsumerWidget {
|
||||||
final isBeta = Store.isBetaTimelineEnabled;
|
final isBeta = Store.isBetaTimelineEnabled;
|
||||||
if (isBeta) {
|
if (isBeta) {
|
||||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||||
|
if (isSyncRemoteDeletionsMode()) {
|
||||||
|
await getManageMediaPermission();
|
||||||
|
}
|
||||||
unawaited(handleSyncFlow());
|
unawaited(handleSyncFlow());
|
||||||
ref.read(websocketProvider.notifier).connect();
|
ref.read(websocketProvider.notifier).connect();
|
||||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||||
|
|
@ -293,6 +346,9 @@ class LoginForm extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
if (isBeta) {
|
if (isBeta) {
|
||||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||||
|
if (isSyncRemoteDeletionsMode()) {
|
||||||
|
await getManageMediaPermission();
|
||||||
|
}
|
||||||
unawaited(handleSyncFlow());
|
unawaited(handleSyncFlow());
|
||||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||||
|
|
@ -17,6 +17,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
|
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
|
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
|
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
|
||||||
|
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
|
||||||
|
|
@ -25,12 +26,15 @@ import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class AdvancedSettings extends HookConsumerWidget {
|
class AdvancedSettings extends HookConsumerWidget {
|
||||||
const AdvancedSettings({super.key});
|
const AdvancedSettings({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
bool isLoggedIn = ref.read(currentUserProvider) != null;
|
bool isLoggedIn = ref.read(currentUserProvider) != null;
|
||||||
|
|
||||||
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||||
|
final isManageMediaSupported = useState(false);
|
||||||
|
final manageMediaAndroidPermission = useState(false);
|
||||||
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
||||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||||
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
|
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
|
||||||
|
|
@ -51,6 +55,18 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
() async {
|
||||||
|
isManageMediaSupported.value = await checkAndroidVersion();
|
||||||
|
if (isManageMediaSupported.value) {
|
||||||
|
manageMediaAndroidPermission.value = await ref
|
||||||
|
.read(localFilesManagerRepositoryProvider)
|
||||||
|
.hasManageMediaPermission();
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
final advancedSettings = [
|
final advancedSettings = [
|
||||||
SettingsSwitchListTile(
|
SettingsSwitchListTile(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -58,11 +74,10 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||||
title: "advanced_settings_troubleshooting_title".tr(),
|
title: "advanced_settings_troubleshooting_title".tr(),
|
||||||
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
|
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
|
||||||
),
|
),
|
||||||
FutureBuilder<bool>(
|
if (isManageMediaSupported.value)
|
||||||
future: checkAndroidVersion(),
|
Column(
|
||||||
builder: (context, snapshot) {
|
children: [
|
||||||
if (snapshot.hasData && snapshot.data == true) {
|
SettingsSwitchListTile(
|
||||||
return SettingsSwitchListTile(
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
valueNotifier: manageLocalMediaAndroid,
|
valueNotifier: manageLocalMediaAndroid,
|
||||||
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
||||||
|
|
@ -71,14 +86,24 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||||
if (value) {
|
if (value) {
|
||||||
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||||
manageLocalMediaAndroid.value = result;
|
manageLocalMediaAndroid.value = result;
|
||||||
|
manageMediaAndroidPermission.value = result;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
} else {
|
SettingsActionTile(
|
||||||
return const SizedBox.shrink();
|
title: "manage_media_access_title".tr(),
|
||||||
}
|
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
|
||||||
},
|
subtitle: "manage_media_access_rationale".tr(),
|
||||||
),
|
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
|
||||||
|
? const Color.fromARGB(255, 243, 188, 106)
|
||||||
|
: null,
|
||||||
|
onActionTap: () async {
|
||||||
|
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
||||||
|
manageMediaAndroidPermission.value = result;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
SettingsSliderListTile(
|
SettingsSliderListTile(
|
||||||
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
|
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
|
||||||
valueNotifier: levelId,
|
valueNotifier: levelId,
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
@ -229,6 +233,7 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||||
final localAlbumService = ref.watch(localAlbumServiceProvider);
|
final localAlbumService = ref.watch(localAlbumServiceProvider);
|
||||||
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
|
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
|
||||||
final memoryService = ref.watch(driftMemoryServiceProvider);
|
final memoryService = ref.watch(driftMemoryServiceProvider);
|
||||||
|
final appSettingsService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
Future<List<dynamic>> loadCounts() async {
|
Future<List<dynamic>> loadCounts() async {
|
||||||
final assetCounts = assetService.getAssetCounts();
|
final assetCounts = assetService.getAssetCounts();
|
||||||
|
|
@ -351,6 +356,44 @@ class _SyncStatsCounts extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// To be removed once the experimental feature is stable
|
||||||
|
if (CurrentPlatform.isAndroid &&
|
||||||
|
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
|
||||||
|
_SectionHeaderText(text: "trash".t(context: context)),
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final counts = ref.watch(trashedAssetsCountProvider);
|
||||||
|
return counts.when(
|
||||||
|
data: (c) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Flex(
|
||||||
|
direction: Axis.horizontal,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
spacing: 8.0,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: EntitiyCountTile(
|
||||||
|
label: "local".t(context: context),
|
||||||
|
count: c.total,
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: EntitiyCountTile(
|
||||||
|
label: "hashed_assets".t(context: context),
|
||||||
|
count: c.hashed,
|
||||||
|
icon: Icons.tag,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
error: (e, st) => Text('Error: $e'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
|
||||||
|
class SettingsActionTile extends StatelessWidget {
|
||||||
|
const SettingsActionTile({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onActionTap,
|
||||||
|
this.statusText,
|
||||||
|
this.statusColor,
|
||||||
|
this.contentPadding,
|
||||||
|
this.titleStyle,
|
||||||
|
this.subtitleStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String? statusText;
|
||||||
|
final Color? statusColor;
|
||||||
|
final VoidCallback onActionTap;
|
||||||
|
final EdgeInsets? contentPadding;
|
||||||
|
final TextStyle? titleStyle;
|
||||||
|
final TextStyle? subtitleStyle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
isThreeLine: true,
|
||||||
|
onTap: onActionTap,
|
||||||
|
titleAlignment: ListTileTitleAlignment.center,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: titleStyle ?? theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500, height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (statusText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Chip(
|
||||||
|
label: Text(
|
||||||
|
statusText!,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: statusColor ?? theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
side: BorderSide(color: statusColor ?? theme.colorScheme.outlineVariant),
|
||||||
|
shape: StadiumBorder(side: BorderSide(color: statusColor ?? theme.colorScheme.outlineVariant)),
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
|
||||||
|
child: Text(
|
||||||
|
subtitle,
|
||||||
|
style: subtitleStyle ?? theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Icon(Icons.arrow_forward_ios, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
contentPadding: contentPadding ?? const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,10 @@ import 'package:pigeon/pigeon.dart';
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
// Follows AssetType enum from base_asset.model.dart
|
// Follows AssetType enum from base_asset.model.dart
|
||||||
final int type;
|
final int type;
|
||||||
|
|
||||||
// Seconds since epoch
|
// Seconds since epoch
|
||||||
final int? createdAt;
|
final int? createdAt;
|
||||||
final int? updatedAt;
|
final int? updatedAt;
|
||||||
|
|
@ -42,6 +44,7 @@ class PlatformAsset {
|
||||||
class PlatformAlbum {
|
class PlatformAlbum {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
// Seconds since epoch
|
// Seconds since epoch
|
||||||
final int? updatedAt;
|
final int? updatedAt;
|
||||||
final bool isCloud;
|
final bool isCloud;
|
||||||
|
|
@ -60,6 +63,7 @@ class SyncDelta {
|
||||||
final bool hasChanges;
|
final bool hasChanges;
|
||||||
final List<PlatformAsset> updates;
|
final List<PlatformAsset> updates;
|
||||||
final List<String> deletes;
|
final List<String> deletes;
|
||||||
|
|
||||||
// Asset -> Album mapping
|
// Asset -> Album mapping
|
||||||
final Map<String, List<String>> assetAlbums;
|
final Map<String, List<String>> assetAlbums;
|
||||||
|
|
||||||
|
|
@ -107,4 +111,7 @@ abstract class NativeSyncApi {
|
||||||
List<HashResult> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false});
|
List<HashResult> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false});
|
||||||
|
|
||||||
void cancelHashing();
|
void cancelHashing();
|
||||||
|
|
||||||
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
|
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,4 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||||
|
|
||||||
class MockUploadService extends Mock implements UploadService {}
|
class MockUploadService extends Mock implements UploadService {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,19 @@ void main() {
|
||||||
late MockLocalAlbumRepository mockAlbumRepo;
|
late MockLocalAlbumRepository mockAlbumRepo;
|
||||||
late MockLocalAssetRepository mockAssetRepo;
|
late MockLocalAssetRepository mockAssetRepo;
|
||||||
late MockNativeSyncApi mockNativeApi;
|
late MockNativeSyncApi mockNativeApi;
|
||||||
|
late MockTrashedLocalAssetRepository mockTrashedAssetRepo;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockAlbumRepo = MockLocalAlbumRepository();
|
mockAlbumRepo = MockLocalAlbumRepository();
|
||||||
mockAssetRepo = MockLocalAssetRepository();
|
mockAssetRepo = MockLocalAssetRepository();
|
||||||
mockNativeApi = MockNativeSyncApi();
|
mockNativeApi = MockNativeSyncApi();
|
||||||
|
mockTrashedAssetRepo = MockTrashedLocalAssetRepository();
|
||||||
|
|
||||||
sut = HashService(
|
sut = HashService(
|
||||||
localAlbumRepository: mockAlbumRepo,
|
localAlbumRepository: mockAlbumRepo,
|
||||||
localAssetRepository: mockAssetRepo,
|
localAssetRepository: mockAssetRepo,
|
||||||
nativeSyncApi: mockNativeApi,
|
nativeSyncApi: mockNativeApi,
|
||||||
|
trashedLocalAssetRepository: mockTrashedAssetRepo,
|
||||||
);
|
);
|
||||||
|
|
||||||
registerFallbackValue(LocalAlbumStub.recent);
|
registerFallbackValue(LocalAlbumStub.recent);
|
||||||
|
|
@ -114,6 +117,7 @@ void main() {
|
||||||
localAssetRepository: mockAssetRepo,
|
localAssetRepository: mockAssetRepo,
|
||||||
nativeSyncApi: mockNativeApi,
|
nativeSyncApi: mockNativeApi,
|
||||||
batchSize: batchSize,
|
batchSize: batchSize,
|
||||||
|
trashedLocalAssetRepository: mockTrashedAssetRepo,
|
||||||
);
|
);
|
||||||
|
|
||||||
final album = LocalAlbumStub.recent;
|
final album = LocalAlbumStub.recent;
|
||||||
|
|
@ -186,4 +190,5 @@ void main() {
|
||||||
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
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/services/local_sync.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.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_album.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 DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||||
|
late LocalFilesManagerRepository mockLocalFilesManager;
|
||||||
|
late StorageRepository mockStorageRepository;
|
||||||
|
late MockNativeSyncApi mockNativeSyncApi;
|
||||||
|
late Drift db;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
|
||||||
|
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||||
|
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
await Store.clear();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||||
|
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||||
|
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
||||||
|
mockStorageRepository = MockStorageRepository();
|
||||||
|
mockNativeSyncApi = MockNativeSyncApi();
|
||||||
|
|
||||||
|
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||||
|
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
|
||||||
|
(_) async => SyncDelta(
|
||||||
|
hasChanges: false,
|
||||||
|
updates: const [],
|
||||||
|
deletes: const [],
|
||||||
|
assetAlbums: const {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
when(() => mockNativeSyncApi.getTrashedAssets()).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);
|
||||||
|
|
||||||
|
sut = LocalSyncService(
|
||||||
|
localAlbumRepository: mockLocalAlbumRepository,
|
||||||
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||||
|
localFilesManager: mockLocalFilesManager,
|
||||||
|
storageRepository: mockStorageRepository,
|
||||||
|
nativeSyncApi: mockNativeSyncApi,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||||
|
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
|
await sut.sync();
|
||||||
|
|
||||||
|
verify(() => mockNativeSyncApi.getTrashedAssets()).called(1);
|
||||||
|
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
|
await sut.sync();
|
||||||
|
|
||||||
|
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
|
|
||||||
|
await sut.sync();
|
||||||
|
|
||||||
|
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips syncTrashedAssets on non-Android platforms', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||||
|
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
|
await sut.sync();
|
||||||
|
|
||||||
|
verifyNever(() => mockNativeSyncApi.getTrashedAssets());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('LocalSyncService - syncTrashedAssets behavior', () {
|
||||||
|
test('processes trashed snapshot, restores assets, and trashes local files', () async {
|
||||||
|
final platformAsset = PlatformAsset(
|
||||||
|
id: 'remote-id',
|
||||||
|
name: 'remote.jpg',
|
||||||
|
type: AssetType.image.index,
|
||||||
|
durationInSeconds: 0,
|
||||||
|
orientation: 0,
|
||||||
|
isFavorite: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
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]});
|
||||||
|
|
||||||
|
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
|
||||||
|
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 => []);
|
||||||
|
|
||||||
|
await sut.processTrashedAssets({});
|
||||||
|
|
||||||
|
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
||||||
|
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not move local assets when repository finds nothing to trash', () async {
|
||||||
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.processTrashedAssets({});
|
||||||
|
|
||||||
|
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
||||||
|
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,30 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
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: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/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/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_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../fixtures/sync_stream.stub.dart';
|
import '../../fixtures/sync_stream.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
import '../../mocks/asset_entity.mock.dart';
|
||||||
|
import '../../repository.mocks.dart';
|
||||||
|
|
||||||
class _AbortCallbackWrapper {
|
class _AbortCallbackWrapper {
|
||||||
const _AbortCallbackWrapper();
|
const _AbortCallbackWrapper();
|
||||||
|
|
@ -30,15 +46,40 @@ void main() {
|
||||||
late SyncStreamService sut;
|
late SyncStreamService sut;
|
||||||
late SyncStreamRepository mockSyncStreamRepo;
|
late SyncStreamRepository mockSyncStreamRepo;
|
||||||
late SyncApiRepository mockSyncApiRepo;
|
late SyncApiRepository mockSyncApiRepo;
|
||||||
|
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||||
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||||
|
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
||||||
|
late StorageRepository mockStorageRepo;
|
||||||
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
|
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
|
||||||
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
|
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
|
||||||
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
|
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
|
||||||
|
late Drift db;
|
||||||
|
late bool hasManageMediaPermission;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
|
|
||||||
|
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||||
|
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
await Store.clear();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
successHandler(Invocation _) async => true;
|
successHandler(Invocation _) async => true;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() async {
|
||||||
mockSyncStreamRepo = MockSyncStreamRepository();
|
mockSyncStreamRepo = MockSyncStreamRepository();
|
||||||
mockSyncApiRepo = MockSyncApiRepository();
|
mockSyncApiRepo = MockSyncApiRepository();
|
||||||
|
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||||
|
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||||
|
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
||||||
|
mockStorageRepo = MockStorageRepository();
|
||||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
|
|
||||||
|
|
@ -87,7 +128,25 @@ void main() {
|
||||||
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
|
when(() => mockSyncStreamRepo.updateAssetFacesV1(any())).thenAnswer(successHandler);
|
||||||
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
|
when(() => mockSyncStreamRepo.deleteAssetFacesV1(any())).thenAnswer(successHandler);
|
||||||
|
|
||||||
sut = SyncStreamService(syncApiRepository: mockSyncApiRepo, syncStreamRepository: mockSyncStreamRepo);
|
sut = SyncStreamService(
|
||||||
|
syncApiRepository: mockSyncApiRepo,
|
||||||
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
|
localFilesManager: mockLocalFilesManagerRepo,
|
||||||
|
storageRepository: mockStorageRepo,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
|
||||||
|
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||||
|
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||||
|
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||||
|
hasManageMediaPermission = false;
|
||||||
|
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||||
|
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
||||||
|
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||||
|
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> simulateEvents(List<SyncEvent> events) async {
|
Future<void> simulateEvents(List<SyncEvent> events) async {
|
||||||
|
|
@ -152,6 +211,10 @@ void main() {
|
||||||
sut = SyncStreamService(
|
sut = SyncStreamService(
|
||||||
syncApiRepository: mockSyncApiRepo,
|
syncApiRepository: mockSyncApiRepo,
|
||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
|
localFilesManager: mockLocalFilesManagerRepo,
|
||||||
|
storageRepository: mockStorageRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
);
|
);
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
@ -187,6 +250,10 @@ void main() {
|
||||||
sut = SyncStreamService(
|
sut = SyncStreamService(
|
||||||
syncApiRepository: mockSyncApiRepo,
|
syncApiRepository: mockSyncApiRepo,
|
||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
|
localFilesManager: mockLocalFilesManagerRepo,
|
||||||
|
storageRepository: mockStorageRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -296,4 +363,127 @@ void main() {
|
||||||
verify(() => mockSyncApiRepo.ack(["5"])).called(1);
|
verify(() => mockSyncApiRepo.ack(["5"])).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group("SyncStreamService - remote trash & restore", () {
|
||||||
|
setUp(() async {
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
|
hasManageMediaPermission = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
|
hasManageMediaPermission = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moves backed up local and merged assets to device trash when remote trash events are received", () async {
|
||||||
|
final localAsset = LocalAssetStub.image1.copyWith(id: 'local-only', checksum: 'checksum-local', remoteId: null);
|
||||||
|
final mergedAsset = LocalAssetStub.image2.copyWith(
|
||||||
|
id: 'merged-local',
|
||||||
|
checksum: 'checksum-merged',
|
||||||
|
remoteId: 'remote-merged',
|
||||||
|
);
|
||||||
|
final assetsByAlbum = {
|
||||||
|
'album-a': [localAsset],
|
||||||
|
'album-b': [mergedAsset],
|
||||||
|
};
|
||||||
|
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||||
|
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
|
||||||
|
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
|
||||||
|
return assetsByAlbum;
|
||||||
|
});
|
||||||
|
|
||||||
|
final localEntity = MockAssetEntity();
|
||||||
|
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
||||||
|
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
||||||
|
|
||||||
|
final mergedEntity = MockAssetEntity();
|
||||||
|
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
|
||||||
|
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
|
||||||
|
|
||||||
|
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
|
||||||
|
final urls = invocation.positionalArguments.first as List<String>;
|
||||||
|
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final events = [
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-1',
|
||||||
|
checksum: localAsset.checksum!,
|
||||||
|
ack: 'asset-remote-local-1',
|
||||||
|
trashedAt: DateTime(2025, 5, 1),
|
||||||
|
),
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-2',
|
||||||
|
checksum: mergedAsset.checksum!,
|
||||||
|
ack: 'asset-remote-merged-2',
|
||||||
|
trashedAt: DateTime(2025, 5, 2),
|
||||||
|
),
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-3',
|
||||||
|
checksum: 'checksum-remote-only',
|
||||||
|
ack: 'asset-remote-only-3',
|
||||||
|
trashedAt: DateTime(2025, 5, 3),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await simulateEvents(events);
|
||||||
|
|
||||||
|
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
||||||
|
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||||
|
final events = [
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-only',
|
||||||
|
checksum: 'checksum-only',
|
||||||
|
ack: 'asset-remote-only-9',
|
||||||
|
trashedAt: DateTime(2025, 6, 1),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await simulateEvents(events);
|
||||||
|
|
||||||
|
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||||
|
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||||
|
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not request local deletions for permanent remote delete events", () async {
|
||||||
|
final events = [SyncStreamStub.assetDeleteV1];
|
||||||
|
|
||||||
|
await simulateEvents(events);
|
||||||
|
|
||||||
|
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
|
||||||
|
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||||
|
verify(() => mockSyncStreamRepo.deleteAssetsV1(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;
|
||||||
|
});
|
||||||
|
|
||||||
|
final events = [
|
||||||
|
SyncStreamStub.assetModified(
|
||||||
|
id: 'remote-1',
|
||||||
|
checksum: 'checksum-trash',
|
||||||
|
ack: 'asset-remote-1-11',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await simulateEvents(events);
|
||||||
|
|
||||||
|
verify(() => mockTrashedLocalAssetRepo.applyRestoredAssets(restoredIds)).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import 'schema_v9.dart' as v9;
|
||||||
import 'schema_v10.dart' as v10;
|
import 'schema_v10.dart' as v10;
|
||||||
import 'schema_v11.dart' as v11;
|
import 'schema_v11.dart' as v11;
|
||||||
import 'schema_v12.dart' as v12;
|
import 'schema_v12.dart' as v12;
|
||||||
|
import 'schema_v13.dart' as v13;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
|
|
@ -44,10 +45,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
return v11.DatabaseAtV11(db);
|
return v11.DatabaseAtV11(db);
|
||||||
case 12:
|
case 12:
|
||||||
return v12.DatabaseAtV12(db);
|
return v12.DatabaseAtV12(db);
|
||||||
|
case 13:
|
||||||
|
return v13.DatabaseAtV13(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -81,4 +81,67 @@ abstract final class SyncStreamStub {
|
||||||
data: SyncMemoryAssetDeleteV1(assetId: "asset-2", memoryId: "memory-1"),
|
data: SyncMemoryAssetDeleteV1(assetId: "asset-2", memoryId: "memory-1"),
|
||||||
ack: "8",
|
ack: "8",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static final assetDeleteV1 = SyncEvent(
|
||||||
|
type: SyncEntityType.assetDeleteV1,
|
||||||
|
data: SyncAssetDeleteV1(assetId: "remote-asset"),
|
||||||
|
ack: "asset-delete-ack",
|
||||||
|
);
|
||||||
|
|
||||||
|
static SyncEvent assetTrashed({
|
||||||
|
required String id,
|
||||||
|
required String checksum,
|
||||||
|
required String ack,
|
||||||
|
DateTime? trashedAt,
|
||||||
|
}) {
|
||||||
|
return _assetV1(
|
||||||
|
id: id,
|
||||||
|
checksum: checksum,
|
||||||
|
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
|
||||||
|
ack: ack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SyncEvent assetModified({
|
||||||
|
required String id,
|
||||||
|
required String checksum,
|
||||||
|
required String ack,
|
||||||
|
}) {
|
||||||
|
return _assetV1(
|
||||||
|
id: id,
|
||||||
|
checksum: checksum,
|
||||||
|
deletedAt: null,
|
||||||
|
ack: ack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SyncEvent _assetV1({
|
||||||
|
required String id,
|
||||||
|
required String checksum,
|
||||||
|
required DateTime? deletedAt,
|
||||||
|
required String ack,
|
||||||
|
}) {
|
||||||
|
return SyncEvent(
|
||||||
|
type: SyncEntityType.assetV1,
|
||||||
|
data: SyncAssetV1(
|
||||||
|
checksum: checksum,
|
||||||
|
deletedAt: deletedAt,
|
||||||
|
duration: '0',
|
||||||
|
fileCreatedAt: DateTime(2025),
|
||||||
|
fileModifiedAt: DateTime(2025, 1, 2),
|
||||||
|
id: id,
|
||||||
|
isFavorite: false,
|
||||||
|
libraryId: null,
|
||||||
|
livePhotoVideoId: null,
|
||||||
|
localDateTime: DateTime(2025, 1, 3),
|
||||||
|
originalFileName: '$id.jpg',
|
||||||
|
ownerId: 'owner',
|
||||||
|
stackId: null,
|
||||||
|
thumbhash: null,
|
||||||
|
type: AssetTypeEnum.IMAGE,
|
||||||
|
visibility: AssetVisibility.timeline,
|
||||||
|
),
|
||||||
|
ack: ack,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
|
|
@ -34,6 +35,8 @@ class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository
|
||||||
|
|
||||||
class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
|
class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
|
||||||
|
|
||||||
|
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||||
|
|
||||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||||
|
|
||||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||||
|
|
@ -12,16 +12,14 @@ import 'package:immich_mobile/infrastructure/repositories/device_asset.repositor
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
import '../fixtures/asset.stub.dart';
|
import '../fixtures/asset.stub.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
import '../service.mocks.dart';
|
import '../service.mocks.dart';
|
||||||
|
import '../mocks/asset_entity.mock.dart';
|
||||||
|
|
||||||
class MockAsset extends Mock implements Asset {}
|
class MockAsset extends Mock implements Asset {}
|
||||||
|
|
||||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late HashService sut;
|
late HashService sut;
|
||||||
late BackgroundService mockBackgroundService;
|
late BackgroundService mockBackgroundService;
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,12 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
import '../domain/service.mock.dart';
|
import '../domain/service.mock.dart';
|
||||||
import '../fixtures/asset.stub.dart';
|
import '../fixtures/asset.stub.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
import '../repository.mocks.dart';
|
import '../repository.mocks.dart';
|
||||||
|
import '../mocks/asset_entity.mock.dart';
|
||||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late UploadService sut;
|
late UploadService sut;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue