mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat: opt-in sync of deletes and restores from web to Android (#16732)
* Features: Local file movement to trash and restoration back to the album added. (Android) * Comments fixes * settings button marked as [EXPERIMENTAL] * _moveToTrashMatchedAssets refactored, moveToTrash renamed. * fix: bad merge * Permission check and request for local storage added. * Permission request added on settings switcher * Settings button logic changed * Method channel file_trash moved to BackgroundServicePlugin --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									6ae24fbbd4
								
							
						
					
					
						commit
						2b131fe935
					
				@ -6,6 +6,7 @@
 | 
			
		||||
    android:maxSdkVersion="32" />
 | 
			
		||||
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
 | 
			
		||||
    android:maxSdkVersion="32" />
 | 
			
		||||
  <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
 | 
			
		||||
  <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
 | 
			
		||||
  <uses-permission android:name="android.permission.MANAGE_MEDIA" />
 | 
			
		||||
  <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
 | 
			
		||||
 | 
			
		||||
@ -1,25 +1,40 @@
 | 
			
		||||
package app.alextran.immich
 | 
			
		||||
 | 
			
		||||
import android.content.ContentResolver
 | 
			
		||||
import android.content.ContentUris
 | 
			
		||||
import android.content.ContentValues
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import android.content.Intent
 | 
			
		||||
import android.net.Uri
 | 
			
		||||
import android.os.Build
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import android.os.Environment
 | 
			
		||||
import android.provider.MediaStore
 | 
			
		||||
import android.provider.Settings
 | 
			
		||||
import android.util.Log
 | 
			
		||||
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.MethodCall
 | 
			
		||||
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.io.FileInputStream
 | 
			
		||||
import kotlinx.coroutines.*
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Android plugin for Dart `BackgroundService`
 | 
			
		||||
 *
 | 
			
		||||
 * Receives messages/method calls from the foreground Dart side to manage
 | 
			
		||||
 * the background service, e.g. start (enqueue), stop (cancel)
 | 
			
		||||
 * Android plugin for Dart `BackgroundService` and file trash operations
 | 
			
		||||
 */
 | 
			
		||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
 | 
			
		||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
 | 
			
		||||
 | 
			
		||||
  private var methodChannel: MethodChannel? = null
 | 
			
		||||
  private var fileTrashChannel: MethodChannel? = null
 | 
			
		||||
  private var context: Context? = null
 | 
			
		||||
  private var pendingResult: Result? = null
 | 
			
		||||
  private val PERMISSION_REQUEST_CODE = 1001
 | 
			
		||||
  private var activityBinding: ActivityPluginBinding? = null
 | 
			
		||||
 | 
			
		||||
  override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
 | 
			
		||||
    onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
 | 
			
		||||
@ -29,6 +44,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
 | 
			
		||||
    context = ctx
 | 
			
		||||
    methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
 | 
			
		||||
    methodChannel?.setMethodCallHandler(this)
 | 
			
		||||
 | 
			
		||||
    // Add file trash channel
 | 
			
		||||
    fileTrashChannel = MethodChannel(messenger, "file_trash")
 | 
			
		||||
    fileTrashChannel?.setMethodCallHandler(this)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
 | 
			
		||||
@ -38,11 +57,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
 | 
			
		||||
  private fun onDetachedFromEngine() {
 | 
			
		||||
    methodChannel?.setMethodCallHandler(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!!
 | 
			
		||||
    when (call.method) {
 | 
			
		||||
      // Existing BackgroundService methods
 | 
			
		||||
      "enable" -> {
 | 
			
		||||
        val args = call.arguments<ArrayList<*>>()!!
 | 
			
		||||
        ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
 | 
			
		||||
@ -114,10 +136,180 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // File Trash methods moved from MainActivity
 | 
			
		||||
      "moveToTrash" -> {
 | 
			
		||||
        val fileName = call.argument<String>("fileName")
 | 
			
		||||
        if (fileName != null) {
 | 
			
		||||
          if (hasManageStoragePermission()) {
 | 
			
		||||
            val success = moveToTrash(fileName)
 | 
			
		||||
            result.success(success)
 | 
			
		||||
          } else {
 | 
			
		||||
            result.error("PERMISSION_DENIED", "Storage permission required", null)
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          result.error("INVALID_NAME", "The file name is not specified.", null)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      "restoreFromTrash" -> {
 | 
			
		||||
        val fileName = call.argument<String>("fileName")
 | 
			
		||||
        if (fileName != null) {
 | 
			
		||||
          if (hasManageStoragePermission()) {
 | 
			
		||||
            val success = untrashImage(fileName)
 | 
			
		||||
            result.success(success)
 | 
			
		||||
          } else {
 | 
			
		||||
            result.error("PERMISSION_DENIED", "Storage permission required", null)
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          result.error("INVALID_NAME", "The file name is not specified.", null)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      "requestManageStoragePermission" -> {
 | 
			
		||||
        if (!hasManageStoragePermission()) {
 | 
			
		||||
          requestManageStoragePermission(result)
 | 
			
		||||
        } else {
 | 
			
		||||
          Log.e("Manage storage permission", "Permission already granted")
 | 
			
		||||
          result.success(true)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      else -> result.notImplemented()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // File Trash methods moved from MainActivity
 | 
			
		||||
  private fun hasManageStoragePermission(): Boolean {
 | 
			
		||||
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
 | 
			
		||||
      Environment.isExternalStorageManager()
 | 
			
		||||
    } else {
 | 
			
		||||
      true
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private fun requestManageStoragePermission(result: Result) {
 | 
			
		||||
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
 | 
			
		||||
      pendingResult = result // Store the result callback
 | 
			
		||||
      val activity = activityBinding?.activity ?: return
 | 
			
		||||
 | 
			
		||||
      val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
 | 
			
		||||
      intent.data = Uri.parse("package:${activity.packageName}")
 | 
			
		||||
      activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
 | 
			
		||||
    } else {
 | 
			
		||||
      result.success(true)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private fun moveToTrash(fileName: String): Boolean {
 | 
			
		||||
    val contentResolver = context?.contentResolver ?: return false
 | 
			
		||||
    val uri = getFileUri(fileName)
 | 
			
		||||
    Log.e("FILE_URI", uri.toString())
 | 
			
		||||
    return uri?.let { moveToTrash(it) } ?: false
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private fun moveToTrash(contentUri: Uri): Boolean {
 | 
			
		||||
    val contentResolver = context?.contentResolver ?: return false
 | 
			
		||||
    return try {
 | 
			
		||||
      val values = ContentValues().apply {
 | 
			
		||||
        put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
 | 
			
		||||
      }
 | 
			
		||||
      val updated = contentResolver.update(contentUri, values, null, null)
 | 
			
		||||
      updated > 0
 | 
			
		||||
    } catch (e: Exception) {
 | 
			
		||||
      Log.e("TrashError", "Error moving to trash", e)
 | 
			
		||||
      false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private fun getFileUri(fileName: String): Uri? {
 | 
			
		||||
    val contentResolver = context?.contentResolver ?: return null
 | 
			
		||||
    val contentUri = MediaStore.Files.getContentUri("external")
 | 
			
		||||
    val projection = arrayOf(MediaStore.Images.Media._ID)
 | 
			
		||||
    val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
 | 
			
		||||
    val selectionArgs = arrayOf(fileName)
 | 
			
		||||
    var fileUri: Uri? = null
 | 
			
		||||
 | 
			
		||||
    contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
 | 
			
		||||
      if (cursor.moveToFirst()) {
 | 
			
		||||
        val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
 | 
			
		||||
        fileUri = ContentUris.withAppendedId(contentUri, id)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return fileUri
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private fun untrashImage(name: String): Boolean {
 | 
			
		||||
    val contentResolver = context?.contentResolver ?: return false
 | 
			
		||||
    val uri = getTrashedFileUri(contentResolver, name)
 | 
			
		||||
    Log.e("FILE_URI", uri.toString())
 | 
			
		||||
    return uri?.let { untrashImage(it) } ?: false
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private fun untrashImage(contentUri: Uri): Boolean {
 | 
			
		||||
    val contentResolver = context?.contentResolver ?: return false
 | 
			
		||||
    return try {
 | 
			
		||||
      val values = ContentValues().apply {
 | 
			
		||||
        put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
 | 
			
		||||
      }
 | 
			
		||||
      val updated = contentResolver.update(contentUri, values, null, null)
 | 
			
		||||
      updated > 0
 | 
			
		||||
    } catch (e: Exception) {
 | 
			
		||||
      Log.e("TrashError", "Error restoring file", e)
 | 
			
		||||
      false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
 | 
			
		||||
    val contentUri = 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(contentUri, projection, queryArgs, null)?.use { cursor ->
 | 
			
		||||
      if (cursor.moveToFirst()) {
 | 
			
		||||
        val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
 | 
			
		||||
        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 == PERMISSION_REQUEST_CODE) {
 | 
			
		||||
      val granted = hasManageStoragePermission()
 | 
			
		||||
      pendingResult?.success(granted)
 | 
			
		||||
      pendingResult = null
 | 
			
		||||
      return true
 | 
			
		||||
    }
 | 
			
		||||
    return false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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.engine.FlutterEngine
 | 
			
		||||
import android.os.Bundle
 | 
			
		||||
import android.content.Intent
 | 
			
		||||
import androidx.annotation.NonNull
 | 
			
		||||
 | 
			
		||||
class MainActivity : FlutterActivity() {
 | 
			
		||||
 | 
			
		||||
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
 | 
			
		||||
    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
 | 
			
		||||
        super.configureFlutterEngine(flutterEngine)
 | 
			
		||||
        flutterEngine.plugins.add(BackgroundServicePlugin())
 | 
			
		||||
        // No need to set up method channel here as it's now handled in the plugin
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -23,6 +23,8 @@
 | 
			
		||||
  "advanced_settings_tile_title": "Advanced",
 | 
			
		||||
  "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
 | 
			
		||||
  "advanced_settings_troubleshooting_title": "Troubleshooting",
 | 
			
		||||
  "advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
 | 
			
		||||
  "advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
 | 
			
		||||
  "album_info_card_backup_album_excluded": "EXCLUDED",
 | 
			
		||||
  "album_info_card_backup_album_included": "INCLUDED",
 | 
			
		||||
  "albums": "Albums",
 | 
			
		||||
 | 
			
		||||
@ -65,6 +65,7 @@ enum StoreKey<T> {
 | 
			
		||||
 | 
			
		||||
  // Video settings
 | 
			
		||||
  loadOriginalVideo<bool>._(136),
 | 
			
		||||
  manageLocalMediaAndroid<bool>._(137),
 | 
			
		||||
 | 
			
		||||
  // Experimental stuff
 | 
			
		||||
  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(String fileName);
 | 
			
		||||
  Future<bool> restoreFromTrash(String fileName);
 | 
			
		||||
  Future<bool> requestManageStoragePermission();
 | 
			
		||||
}
 | 
			
		||||
@ -23,6 +23,7 @@ enum PendingAction {
 | 
			
		||||
  assetDelete,
 | 
			
		||||
  assetUploaded,
 | 
			
		||||
  assetHidden,
 | 
			
		||||
  assetTrash,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class PendingChange {
 | 
			
		||||
@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
			
		||||
        socket.on('on_upload_success', _handleOnUploadSuccess);
 | 
			
		||||
        socket.on('on_config_update', _handleOnConfigUpdate);
 | 
			
		||||
        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_update', _handleServerUpdates);
 | 
			
		||||
        socket.on('on_asset_stack_update', _handleServerUpdates);
 | 
			
		||||
@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
			
		||||
    _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 {
 | 
			
		||||
    final deleteChanges = state.pendingChanges
 | 
			
		||||
        .where((c) => c.action == PendingAction.assetDelete)
 | 
			
		||||
@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
			
		||||
    await _handlePendingUploaded();
 | 
			
		||||
    await _handlePendingDeletes();
 | 
			
		||||
    await _handlingPendingHidden();
 | 
			
		||||
    await _handlePendingTrashes();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _handleOnConfigUpdate(dynamic _) {
 | 
			
		||||
@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
			
		||||
  void _handleOnAssetDelete(dynamic data) =>
 | 
			
		||||
      addPendingChange(PendingAction.assetDelete, data);
 | 
			
		||||
 | 
			
		||||
  void _handleOnAssetTrash(dynamic data) {
 | 
			
		||||
    addPendingChange(PendingAction.assetTrash, data);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _handleOnAssetHidden(dynamic data) =>
 | 
			
		||||
      addPendingChange(PendingAction.assetHidden, data);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										23
									
								
								mobile/lib/repositories/local_files_manager.repository.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								mobile/lib/repositories/local_files_manager.repository.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
			
		||||
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) => LocalFilesManagerRepository());
 | 
			
		||||
 | 
			
		||||
class LocalFilesManagerRepository implements ILocalFilesManager {
 | 
			
		||||
  @override
 | 
			
		||||
  Future<bool> moveToTrash(String fileName) async {
 | 
			
		||||
    return await LocalFilesManager.moveToTrash(fileName);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<bool> restoreFromTrash(String fileName) async {
 | 
			
		||||
    return await LocalFilesManager.restoreFromTrash(fileName);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Future<bool> requestManageStoragePermission() async {
 | 
			
		||||
    return await LocalFilesManager.requestManageStoragePermission();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
 | 
			
		||||
    0,
 | 
			
		||||
  ),
 | 
			
		||||
  advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
 | 
			
		||||
  manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
 | 
			
		||||
  logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
 | 
			
		||||
  preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
 | 
			
		||||
  loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
@ -16,6 +17,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/album_media.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/partner.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
 | 
			
		||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
 | 
			
		||||
@ -25,6 +28,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/repositories/album_media.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/repositories/asset.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/repositories/etag.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/repositories/partner.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
 | 
			
		||||
import 'package:immich_mobile/services/entity.service.dart';
 | 
			
		||||
@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
 | 
			
		||||
    ref.watch(userRepositoryProvider),
 | 
			
		||||
    ref.watch(userServiceProvider),
 | 
			
		||||
    ref.watch(etagRepositoryProvider),
 | 
			
		||||
    ref.watch(appSettingsServiceProvider),
 | 
			
		||||
    ref.watch(localFilesManagerRepositoryProvider),
 | 
			
		||||
    ref.watch(partnerApiRepositoryProvider),
 | 
			
		||||
    ref.watch(userApiRepositoryProvider),
 | 
			
		||||
  ),
 | 
			
		||||
@ -69,6 +76,8 @@ class SyncService {
 | 
			
		||||
  final IUserApiRepository _userApiRepository;
 | 
			
		||||
  final AsyncMutex _lock = AsyncMutex();
 | 
			
		||||
  final Logger _log = Logger('SyncService');
 | 
			
		||||
  final AppSettingsService _appSettingsService;
 | 
			
		||||
  final ILocalFilesManager _localFilesManager;
 | 
			
		||||
 | 
			
		||||
  SyncService(
 | 
			
		||||
    this._hashService,
 | 
			
		||||
@ -82,6 +91,8 @@ class SyncService {
 | 
			
		||||
    this._userRepository,
 | 
			
		||||
    this._userService,
 | 
			
		||||
    this._eTagRepository,
 | 
			
		||||
    this._appSettingsService,
 | 
			
		||||
    this._localFilesManager,
 | 
			
		||||
    this._partnerApiRepository,
 | 
			
		||||
    this._userApiRepository,
 | 
			
		||||
  );
 | 
			
		||||
@ -238,8 +249,19 @@ class SyncService {
 | 
			
		||||
    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();
 | 
			
		||||
 | 
			
		||||
    for (var asset in matchedAssets) {
 | 
			
		||||
      _localFilesManager.moveToTrash(asset.fileName);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// 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 {
 | 
			
		||||
      await _assetRepository.deleteAllByRemoteId(
 | 
			
		||||
        idsToDelete,
 | 
			
		||||
@ -249,6 +271,12 @@ class SyncService {
 | 
			
		||||
        idsToDelete,
 | 
			
		||||
        state: AssetState.merged,
 | 
			
		||||
      );
 | 
			
		||||
      if (Platform.isAndroid &&
 | 
			
		||||
          _appSettingsService.getSetting<bool>(
 | 
			
		||||
            AppSettingsEnum.manageLocalMediaAndroid,
 | 
			
		||||
          )) {
 | 
			
		||||
        await _moveToTrashMatchedAssets(idsToDelete);
 | 
			
		||||
      }
 | 
			
		||||
      if (merged.isEmpty) return;
 | 
			
		||||
      for (final Asset asset in merged) {
 | 
			
		||||
        asset.remoteId = null;
 | 
			
		||||
@ -790,9 +818,27 @@ class SyncService {
 | 
			
		||||
    return (existing, toUpsert);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
 | 
			
		||||
    for (var asset in assetsList) {
 | 
			
		||||
      if (asset.isTrashed) {
 | 
			
		||||
        _localFilesManager.moveToTrash(asset.fileName);
 | 
			
		||||
      } else {
 | 
			
		||||
        _localFilesManager.restoreFromTrash(asset.fileName);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Inserts or updates the assets in the database with their ExifInfo (if any)
 | 
			
		||||
  Future<void> upsertAssetsWithExif(List<Asset> assets) async {
 | 
			
		||||
    if (assets.isEmpty) return;
 | 
			
		||||
 | 
			
		||||
    if (Platform.isAndroid &&
 | 
			
		||||
        _appSettingsService.getSetting<bool>(
 | 
			
		||||
          AppSettingsEnum.manageLocalMediaAndroid,
 | 
			
		||||
        )) {
 | 
			
		||||
      _toggleTrashStatusForAssets(assets);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList();
 | 
			
		||||
    try {
 | 
			
		||||
      await _assetRepository.transaction(() async {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										39
									
								
								mobile/lib/utils/local_files_manager.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								mobile/lib/utils/local_files_manager.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
 | 
			
		||||
class LocalFilesManager {
 | 
			
		||||
  static const MethodChannel _channel = MethodChannel('file_trash');
 | 
			
		||||
 | 
			
		||||
  static Future<bool> moveToTrash(String fileName) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final bool success =
 | 
			
		||||
          await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
 | 
			
		||||
      return success;
 | 
			
		||||
    } on PlatformException catch (e) {
 | 
			
		||||
      debugPrint('Error moving to trash: ${e.message}');
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<bool> restoreFromTrash(String fileName) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final bool success = await _channel
 | 
			
		||||
          .invokeMethod('restoreFromTrash', {'fileName': fileName});
 | 
			
		||||
      return success;
 | 
			
		||||
    } on PlatformException catch (e) {
 | 
			
		||||
      debugPrint('Error restoring file: ${e.message}');
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static Future<bool> requestManageStoragePermission() async {
 | 
			
		||||
    try {
 | 
			
		||||
      final bool success =
 | 
			
		||||
          await _channel.invokeMethod('requestManageStoragePermission');
 | 
			
		||||
      return success;
 | 
			
		||||
    } on PlatformException catch (e) {
 | 
			
		||||
      debugPrint('Error requesting permission: ${e.message}');
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,11 +1,13 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/domain/services/log.service.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/utils/hooks/app_settings_update_hook.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
 | 
			
		||||
@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    final advancedTroubleshooting =
 | 
			
		||||
        useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
 | 
			
		||||
    final manageLocalMediaAndroid =
 | 
			
		||||
        useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
 | 
			
		||||
    final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
 | 
			
		||||
    final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
 | 
			
		||||
    final allowSelfSignedSSLCert =
 | 
			
		||||
@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
 | 
			
		||||
          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 >= 30;
 | 
			
		||||
      }
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final advancedSettings = [
 | 
			
		||||
      SettingsSwitchListTile(
 | 
			
		||||
        enabled: true,
 | 
			
		||||
@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
 | 
			
		||||
        title: "advanced_settings_troubleshooting_title".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)
 | 
			
		||||
                      .requestManageStoragePermission();
 | 
			
		||||
                  manageLocalMediaAndroid.value = result;
 | 
			
		||||
                }
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
          } else {
 | 
			
		||||
            return const SizedBox.shrink();
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
      SettingsSliderListTile(
 | 
			
		||||
        text: "advanced_settings_log_level_title".tr(args: [logLevel]),
 | 
			
		||||
        valueNotifier: levelId,
 | 
			
		||||
 | 
			
		||||
@ -60,6 +60,9 @@ void main() {
 | 
			
		||||
    final MockAlbumMediaRepository albumMediaRepository =
 | 
			
		||||
        MockAlbumMediaRepository();
 | 
			
		||||
    final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
 | 
			
		||||
    final MockAppSettingService appSettingService = MockAppSettingService();
 | 
			
		||||
    final MockLocalFilesManagerRepository localFilesManagerRepository =
 | 
			
		||||
        MockLocalFilesManagerRepository();
 | 
			
		||||
    final MockPartnerApiRepository partnerApiRepository =
 | 
			
		||||
        MockPartnerApiRepository();
 | 
			
		||||
    final MockUserApiRepository userApiRepository = MockUserApiRepository();
 | 
			
		||||
@ -106,6 +109,8 @@ void main() {
 | 
			
		||||
        userRepository,
 | 
			
		||||
        userService,
 | 
			
		||||
        eTagRepository,
 | 
			
		||||
        appSettingService,
 | 
			
		||||
        localFilesManagerRepository,
 | 
			
		||||
        partnerApiRepository,
 | 
			
		||||
        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/etag.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_api.interface.dart';
 | 
			
		||||
import 'package:mocktail/mocktail.dart';
 | 
			
		||||
@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
 | 
			
		||||
 | 
			
		||||
class MockAuthRepository extends Mock implements IAuthRepository {}
 | 
			
		||||
 | 
			
		||||
class MockPartnerRepository extends Mock implements IPartnerRepository {}
 | 
			
		||||
 | 
			
		||||
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/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/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/services/entity.service.dart';
 | 
			
		||||
@ -25,4 +26,7 @@ class MockNetworkService extends Mock implements NetworkService {}
 | 
			
		||||
 | 
			
		||||
class MockSearchApi extends Mock implements SearchApi {}
 | 
			
		||||
 | 
			
		||||
class MockAppSettingService extends Mock implements AppSettingsService {}
 | 
			
		||||
 | 
			
		||||
class MockBackgroundService extends Mock implements BackgroundService {}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user