mirror of
https://github.com/immich-app/immich.git
synced 2025-06-02 21:24:28 -04:00
fix(mobile): auto trash using MANAGE_MEDIA (#17828)
fix: auto trash using MANAGE_MEDIA Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
a03902f174
commit
b037158028
@ -1,25 +1,42 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.plugin.common.MethodChannel.Result
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Android plugin for Dart `BackgroundService`
|
* Android plugin for Dart `BackgroundService` and file trash operations
|
||||||
*
|
|
||||||
* Receives messages/method calls from the foreground Dart side to manage
|
|
||||||
* the background service, e.g. start (enqueue), stop (cancel)
|
|
||||||
*/
|
*/
|
||||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
|
||||||
|
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var fileTrashChannel: MethodChannel? = null
|
||||||
private var context: Context? = null
|
private var context: Context? = null
|
||||||
|
private var pendingResult: Result? = null
|
||||||
|
private val permissionRequestCode = 1001
|
||||||
|
private val trashRequestCode = 1002
|
||||||
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
||||||
@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
context = ctx
|
context = ctx
|
||||||
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
|
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
|
||||||
methodChannel?.setMethodCallHandler(this)
|
methodChannel?.setMethodCallHandler(this)
|
||||||
|
|
||||||
|
// Add file trash channel
|
||||||
|
fileTrashChannel = MethodChannel(messenger, "file_trash")
|
||||||
|
fileTrashChannel?.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
private fun onDetachedFromEngine() {
|
private fun onDetachedFromEngine() {
|
||||||
methodChannel?.setMethodCallHandler(null)
|
methodChannel?.setMethodCallHandler(null)
|
||||||
methodChannel = null
|
methodChannel = null
|
||||||
|
fileTrashChannel?.setMethodCallHandler(null)
|
||||||
|
fileTrashChannel = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||||
val ctx = context!!
|
val ctx = context!!
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
// Existing BackgroundService methods
|
||||||
"enable" -> {
|
"enable" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
@ -114,10 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File Trash methods moved from MainActivity
|
||||||
|
"moveToTrash" -> {
|
||||||
|
val mediaUrls = call.argument<List<String>>("mediaUrls")
|
||||||
|
if (mediaUrls != null) {
|
||||||
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
|
moveToTrash(mediaUrls, result)
|
||||||
|
} else {
|
||||||
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"restoreFromTrash" -> {
|
||||||
|
val fileName = call.argument<String>("fileName")
|
||||||
|
val type = call.argument<Int>("type")
|
||||||
|
if (fileName != null && type != null) {
|
||||||
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
|
restoreFromTrash(fileName, type, result)
|
||||||
|
} else {
|
||||||
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_NAME", "The file name is not specified.", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"requestManageMediaPermission" -> {
|
||||||
|
if (!hasManageMediaPermission()) {
|
||||||
|
requestManageMediaPermission(result)
|
||||||
|
} else {
|
||||||
|
Log.e("Manage storage permission", "Permission already granted")
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasManageMediaPermission(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaStore.canManageMedia(context!!);
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestManageMediaPermission(result: Result) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
pendingResult = result // Store the result callback
|
||||||
|
val activity = activityBinding?.activity ?: return
|
||||||
|
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
|
||||||
|
intent.data = "package:${activity.packageName}".toUri()
|
||||||
|
activity.startActivityForResult(intent, permissionRequestCode)
|
||||||
|
} else {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun moveToTrash(mediaUrls: List<String>, result: Result) {
|
||||||
|
val urisToTrash = mediaUrls.map { it.toUri() }
|
||||||
|
if (urisToTrash.isEmpty()) {
|
||||||
|
result.error("INVALID_ARGS", "No valid URIs provided", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTrash(urisToTrash, true, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun restoreFromTrash(name: String, type: Int, result: Result) {
|
||||||
|
val uri = getTrashedFileUri(name, type)
|
||||||
|
if (uri == null) {
|
||||||
|
Log.e("TrashError", "Asset Uri cannot be found obtained")
|
||||||
|
result.error("TrashError", "Asset Uri cannot be found obtained", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.e("FILE_URI", uri.toString())
|
||||||
|
uri.let { toggleTrash(listOf(it), false, result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
|
||||||
|
val contentResolver = context?.contentResolver ?: return null
|
||||||
|
val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
|
||||||
|
|
||||||
|
val queryArgs = Bundle().apply {
|
||||||
|
putString(
|
||||||
|
ContentResolver.QUERY_ARG_SQL_SELECTION,
|
||||||
|
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
|
||||||
|
)
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
|
||||||
|
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
||||||
|
// same order as AssetType from dart
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityAware implementation
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
activityBinding?.removeActivityResultListener(this)
|
||||||
|
activityBinding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
activityBinding?.removeActivityResultListener(this)
|
||||||
|
activityBinding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityResultListener implementation
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode == permissionRequestCode) {
|
||||||
|
val granted = hasManageMediaPermission()
|
||||||
|
pendingResult?.success(granted)
|
||||||
|
pendingResult = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestCode == trashRequestCode) {
|
||||||
|
val approved = resultCode == Activity.RESULT_OK
|
||||||
|
pendingResult?.success(approved)
|
||||||
|
pendingResult = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "BackgroundServicePlugin"
|
private const val TAG = "BackgroundServicePlugin"
|
||||||
private const val BUFFER_SIZE = 2 * 1024 * 1024;
|
private const val BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
|
@ -2,14 +2,12 @@ package app.alextran.immich
|
|||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import android.os.Bundle
|
import androidx.annotation.NonNull
|
||||||
import android.content.Intent
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
|
// No need to set up method channel here as it's now handled in the plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -65,6 +65,7 @@ enum StoreKey<T> {
|
|||||||
|
|
||||||
// Video settings
|
// Video settings
|
||||||
loadOriginalVideo<bool>._(136),
|
loadOriginalVideo<bool>._(136),
|
||||||
|
manageLocalMediaAndroid<bool>._(137),
|
||||||
|
|
||||||
// Experimental stuff
|
// Experimental stuff
|
||||||
photoManagerCustomFilter<bool>._(1000);
|
photoManagerCustomFilter<bool>._(1000);
|
||||||
|
5
mobile/lib/interfaces/local_files_manager.interface.dart
Normal file
5
mobile/lib/interfaces/local_files_manager.interface.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
abstract interface class ILocalFilesManager {
|
||||||
|
Future<bool> moveToTrash(List<String> mediaUrls);
|
||||||
|
Future<bool> restoreFromTrash(String fileName, int type);
|
||||||
|
Future<bool> requestManageMediaPermission();
|
||||||
|
}
|
@ -23,6 +23,7 @@ enum PendingAction {
|
|||||||
assetDelete,
|
assetDelete,
|
||||||
assetUploaded,
|
assetUploaded,
|
||||||
assetHidden,
|
assetHidden,
|
||||||
|
assetTrash,
|
||||||
}
|
}
|
||||||
|
|
||||||
class PendingChange {
|
class PendingChange {
|
||||||
@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
socket.on('on_upload_success', _handleOnUploadSuccess);
|
socket.on('on_upload_success', _handleOnUploadSuccess);
|
||||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||||
socket.on('on_asset_delete', _handleOnAssetDelete);
|
socket.on('on_asset_delete', _handleOnAssetDelete);
|
||||||
socket.on('on_asset_trash', _handleServerUpdates);
|
socket.on('on_asset_trash', _handleOnAssetTrash);
|
||||||
socket.on('on_asset_restore', _handleServerUpdates);
|
socket.on('on_asset_restore', _handleServerUpdates);
|
||||||
socket.on('on_asset_update', _handleServerUpdates);
|
socket.on('on_asset_update', _handleServerUpdates);
|
||||||
socket.on('on_asset_stack_update', _handleServerUpdates);
|
socket.on('on_asset_stack_update', _handleServerUpdates);
|
||||||
@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
_debounce.run(handlePendingChanges);
|
_debounce.run(handlePendingChanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePendingTrashes() async {
|
||||||
|
final trashChanges = state.pendingChanges
|
||||||
|
.where((c) => c.action == PendingAction.assetTrash)
|
||||||
|
.toList();
|
||||||
|
if (trashChanges.isNotEmpty) {
|
||||||
|
List<String> remoteIds = trashChanges
|
||||||
|
.expand((a) => (a.value as List).map((e) => e.toString()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||||
|
await _ref.read(assetProvider.notifier).getAllAsset();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
pendingChanges: state.pendingChanges
|
||||||
|
.whereNot((c) => trashChanges.contains(c))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handlePendingDeletes() async {
|
Future<void> _handlePendingDeletes() async {
|
||||||
final deleteChanges = state.pendingChanges
|
final deleteChanges = state.pendingChanges
|
||||||
.where((c) => c.action == PendingAction.assetDelete)
|
.where((c) => c.action == PendingAction.assetDelete)
|
||||||
@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
await _handlePendingUploaded();
|
await _handlePendingUploaded();
|
||||||
await _handlePendingDeletes();
|
await _handlePendingDeletes();
|
||||||
await _handlingPendingHidden();
|
await _handlingPendingHidden();
|
||||||
|
await _handlePendingTrashes();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleOnConfigUpdate(dynamic _) {
|
void _handleOnConfigUpdate(dynamic _) {
|
||||||
@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
void _handleOnAssetDelete(dynamic data) =>
|
void _handleOnAssetDelete(dynamic data) =>
|
||||||
addPendingChange(PendingAction.assetDelete, data);
|
addPendingChange(PendingAction.assetDelete, data);
|
||||||
|
|
||||||
|
void _handleOnAssetTrash(dynamic data) {
|
||||||
|
addPendingChange(PendingAction.assetTrash, data);
|
||||||
|
}
|
||||||
|
|
||||||
void _handleOnAssetHidden(dynamic data) =>
|
void _handleOnAssetHidden(dynamic data) =>
|
||||||
addPendingChange(PendingAction.assetHidden, data);
|
addPendingChange(PendingAction.assetHidden, data);
|
||||||
|
|
||||||
|
25
mobile/lib/repositories/local_files_manager.repository.dart
Normal file
25
mobile/lib/repositories/local_files_manager.repository.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||||
|
import 'package:immich_mobile/utils/local_files_manager.dart';
|
||||||
|
|
||||||
|
final localFilesManagerRepositoryProvider =
|
||||||
|
Provider((ref) => const LocalFilesManagerRepository());
|
||||||
|
|
||||||
|
class LocalFilesManagerRepository implements ILocalFilesManager {
|
||||||
|
const LocalFilesManagerRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||||
|
return await LocalFilesManager.moveToTrash(mediaUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||||
|
return await LocalFilesManager.restoreFromTrash(fileName, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestManageMediaPermission() async {
|
||||||
|
return await LocalFilesManager.requestManageMediaPermission();
|
||||||
|
}
|
||||||
|
}
|
@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
|
|||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||||
|
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@ -16,8 +17,10 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
|
|||||||
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/partner.interface.dart';
|
import 'package:immich_mobile/interfaces/partner.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/album.repository.dart';
|
import 'package:immich_mobile/repositories/album.repository.dart';
|
||||||
@ -25,8 +28,10 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
|
|||||||
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/etag.repository.dart';
|
import 'package:immich_mobile/repositories/etag.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/partner.repository.dart';
|
import 'package:immich_mobile/repositories/partner.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/entity.service.dart';
|
import 'package:immich_mobile/services/entity.service.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
|
|||||||
ref.watch(userRepositoryProvider),
|
ref.watch(userRepositoryProvider),
|
||||||
ref.watch(userServiceProvider),
|
ref.watch(userServiceProvider),
|
||||||
ref.watch(etagRepositoryProvider),
|
ref.watch(etagRepositoryProvider),
|
||||||
|
ref.watch(appSettingsServiceProvider),
|
||||||
|
ref.watch(localFilesManagerRepositoryProvider),
|
||||||
ref.watch(partnerApiRepositoryProvider),
|
ref.watch(partnerApiRepositoryProvider),
|
||||||
ref.watch(userApiRepositoryProvider),
|
ref.watch(userApiRepositoryProvider),
|
||||||
),
|
),
|
||||||
@ -69,6 +76,8 @@ class SyncService {
|
|||||||
final IUserApiRepository _userApiRepository;
|
final IUserApiRepository _userApiRepository;
|
||||||
final AsyncMutex _lock = AsyncMutex();
|
final AsyncMutex _lock = AsyncMutex();
|
||||||
final Logger _log = Logger('SyncService');
|
final Logger _log = Logger('SyncService');
|
||||||
|
final AppSettingsService _appSettingsService;
|
||||||
|
final ILocalFilesManager _localFilesManager;
|
||||||
|
|
||||||
SyncService(
|
SyncService(
|
||||||
this._hashService,
|
this._hashService,
|
||||||
@ -82,6 +91,8 @@ class SyncService {
|
|||||||
this._userRepository,
|
this._userRepository,
|
||||||
this._userService,
|
this._userService,
|
||||||
this._eTagRepository,
|
this._eTagRepository,
|
||||||
|
this._appSettingsService,
|
||||||
|
this._localFilesManager,
|
||||||
this._partnerApiRepository,
|
this._partnerApiRepository,
|
||||||
this._userApiRepository,
|
this._userApiRepository,
|
||||||
);
|
);
|
||||||
@ -238,8 +249,22 @@ class SyncService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
|
||||||
|
final List<Asset> localAssets = await _assetRepository.getAllLocal();
|
||||||
|
final List<Asset> matchedAssets = localAssets
|
||||||
|
.where((asset) => idsToDelete.contains(asset.remoteId))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final mediaUrls = await Future.wait(
|
||||||
|
matchedAssets
|
||||||
|
.map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes remote-only assets, updates merged assets to be local-only
|
/// Deletes remote-only assets, updates merged assets to be local-only
|
||||||
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
|
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
|
||||||
return _assetRepository.transaction(() async {
|
return _assetRepository.transaction(() async {
|
||||||
await _assetRepository.deleteAllByRemoteId(
|
await _assetRepository.deleteAllByRemoteId(
|
||||||
idsToDelete,
|
idsToDelete,
|
||||||
@ -249,6 +274,12 @@ class SyncService {
|
|||||||
idsToDelete,
|
idsToDelete,
|
||||||
state: AssetState.merged,
|
state: AssetState.merged,
|
||||||
);
|
);
|
||||||
|
if (Platform.isAndroid &&
|
||||||
|
_appSettingsService.getSetting<bool>(
|
||||||
|
AppSettingsEnum.manageLocalMediaAndroid,
|
||||||
|
)) {
|
||||||
|
await _moveToTrashMatchedAssets(idsToDelete);
|
||||||
|
}
|
||||||
if (merged.isEmpty) return;
|
if (merged.isEmpty) return;
|
||||||
for (final Asset asset in merged) {
|
for (final Asset asset in merged) {
|
||||||
asset.remoteId = null;
|
asset.remoteId = null;
|
||||||
@ -790,10 +821,43 @@ class SyncService {
|
|||||||
return (existing, toUpsert);
|
return (existing, toUpsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
|
||||||
|
final trashMediaUrls = <String>[];
|
||||||
|
|
||||||
|
for (final asset in assetsList) {
|
||||||
|
if (asset.isTrashed) {
|
||||||
|
final mediaUrl = await asset.local?.getMediaUrl();
|
||||||
|
if (mediaUrl == null) {
|
||||||
|
_log.warning(
|
||||||
|
"Failed to get media URL for asset ${asset.name} while moving to trash",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
trashMediaUrls.add(mediaUrl);
|
||||||
|
} else {
|
||||||
|
await _localFilesManager.restoreFromTrash(
|
||||||
|
asset.fileName,
|
||||||
|
asset.type.index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trashMediaUrls.isNotEmpty) {
|
||||||
|
await _localFilesManager.moveToTrash(trashMediaUrls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||||
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
||||||
if (assets.isEmpty) return;
|
if (assets.isEmpty) return;
|
||||||
|
|
||||||
|
if (Platform.isAndroid &&
|
||||||
|
_appSettingsService.getSetting<bool>(
|
||||||
|
AppSettingsEnum.manageLocalMediaAndroid,
|
||||||
|
)) {
|
||||||
|
_toggleTrashStatusForAssets(assets);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _assetRepository.transaction(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _assetRepository.updateAll(assets);
|
await _assetRepository.updateAll(assets);
|
||||||
|
38
mobile/lib/utils/local_files_manager.dart
Normal file
38
mobile/lib/utils/local_files_manager.dart
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
abstract final class LocalFilesManager {
|
||||||
|
static final Logger _logger = Logger('LocalFilesManager');
|
||||||
|
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||||
|
|
||||||
|
static Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||||
|
try {
|
||||||
|
return await _channel
|
||||||
|
.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.warning('Error moving file to trash', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod(
|
||||||
|
'restoreFromTrash',
|
||||||
|
{'fileName': fileName, 'type': type},
|
||||||
|
);
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.warning('Error restore file from trash', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> requestManageMediaPermission() async {
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.warning('Error requesting manage media permission', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,13 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.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';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||||
@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
|
|
||||||
final advancedTroubleshooting =
|
final advancedTroubleshooting =
|
||||||
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||||
|
final manageLocalMediaAndroid =
|
||||||
|
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||||
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
||||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||||
final allowSelfSignedSSLCert =
|
final allowSelfSignedSSLCert =
|
||||||
@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
|
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<bool> checkAndroidVersion() async {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
||||||
|
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
||||||
|
int sdkVersion = androidInfo.version.sdkInt;
|
||||||
|
return sdkVersion >= 31;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final advancedSettings = [
|
final advancedSettings = [
|
||||||
SettingsSwitchListTile(
|
SettingsSwitchListTile(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -47,6 +61,29 @@ 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>(
|
||||||
|
future: checkAndroidVersion(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData && snapshot.data == true) {
|
||||||
|
return SettingsSwitchListTile(
|
||||||
|
enabled: true,
|
||||||
|
valueNotifier: manageLocalMediaAndroid,
|
||||||
|
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
||||||
|
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value) {
|
||||||
|
final result = await ref
|
||||||
|
.read(localFilesManagerRepositoryProvider)
|
||||||
|
.requestManageMediaPermission();
|
||||||
|
manageLocalMediaAndroid.value = result;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
SettingsSliderListTile(
|
SettingsSliderListTile(
|
||||||
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
|
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
|
||||||
valueNotifier: levelId,
|
valueNotifier: levelId,
|
||||||
|
@ -60,6 +60,9 @@ void main() {
|
|||||||
final MockAlbumMediaRepository albumMediaRepository =
|
final MockAlbumMediaRepository albumMediaRepository =
|
||||||
MockAlbumMediaRepository();
|
MockAlbumMediaRepository();
|
||||||
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
|
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
|
||||||
|
final MockAppSettingService appSettingService = MockAppSettingService();
|
||||||
|
final MockLocalFilesManagerRepository localFilesManagerRepository =
|
||||||
|
MockLocalFilesManagerRepository();
|
||||||
final MockPartnerApiRepository partnerApiRepository =
|
final MockPartnerApiRepository partnerApiRepository =
|
||||||
MockPartnerApiRepository();
|
MockPartnerApiRepository();
|
||||||
final MockUserApiRepository userApiRepository = MockUserApiRepository();
|
final MockUserApiRepository userApiRepository = MockUserApiRepository();
|
||||||
@ -106,6 +109,8 @@ void main() {
|
|||||||
userRepository,
|
userRepository,
|
||||||
userService,
|
userService,
|
||||||
eTagRepository,
|
eTagRepository,
|
||||||
|
appSettingService,
|
||||||
|
localFilesManagerRepository,
|
||||||
partnerApiRepository,
|
partnerApiRepository,
|
||||||
userApiRepository,
|
userApiRepository,
|
||||||
);
|
);
|
||||||
|
@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
|||||||
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
|
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/partner.interface.dart';
|
import 'package:immich_mobile/interfaces/partner.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
|
|||||||
|
|
||||||
class MockAuthRepository extends Mock implements IAuthRepository {}
|
class MockAuthRepository extends Mock implements IAuthRepository {}
|
||||||
|
|
||||||
|
class MockPartnerRepository extends Mock implements IPartnerRepository {}
|
||||||
|
|
||||||
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
|
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
|
||||||
|
|
||||||
class MockPartnerRepository extends Mock implements IPartnerRepository {}
|
class MockLocalFilesManagerRepository extends Mock
|
||||||
|
implements ILocalFilesManager {}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:immich_mobile/services/album.service.dart';
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/services/entity.service.dart';
|
import 'package:immich_mobile/services/entity.service.dart';
|
||||||
@ -25,4 +26,6 @@ class MockNetworkService extends Mock implements NetworkService {}
|
|||||||
|
|
||||||
class MockSearchApi extends Mock implements SearchApi {}
|
class MockSearchApi extends Mock implements SearchApi {}
|
||||||
|
|
||||||
|
class MockAppSettingService extends Mock implements AppSettingsService {}
|
||||||
|
|
||||||
class MockBackgroundService extends Mock implements BackgroundService {}
|
class MockBackgroundService extends Mock implements BackgroundService {}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user