Skip to content

Commit 2b131fe

Browse files
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 <[email protected]>
1 parent 6ae24fb commit 2b131fe

File tree

15 files changed

+400
-16
lines changed

15 files changed

+400
-16
lines changed

mobile/android/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
android:maxSdkVersion="32" />
77
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
88
android:maxSdkVersion="32" />
9+
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
910
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
1011
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
1112
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@@ -124,4 +125,4 @@
124125
<data android:scheme="geo" />
125126
</intent>
126127
</queries>
127-
</manifest>
128+
</manifest>

mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt

Lines changed: 199 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,40 @@
11
package app.alextran.immich
22

3+
import android.content.ContentResolver
4+
import android.content.ContentUris
5+
import android.content.ContentValues
36
import android.content.Context
7+
import android.content.Intent
8+
import android.net.Uri
9+
import android.os.Build
10+
import android.os.Bundle
11+
import android.os.Environment
12+
import android.provider.MediaStore
13+
import android.provider.Settings
414
import android.util.Log
515
import io.flutter.embedding.engine.plugins.FlutterPlugin
16+
import io.flutter.embedding.engine.plugins.activity.ActivityAware
17+
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
618
import io.flutter.plugin.common.BinaryMessenger
719
import io.flutter.plugin.common.MethodCall
820
import io.flutter.plugin.common.MethodChannel
21+
import io.flutter.plugin.common.MethodChannel.Result
22+
import io.flutter.plugin.common.PluginRegistry
923
import java.security.MessageDigest
1024
import java.io.FileInputStream
1125
import kotlinx.coroutines.*
1226

1327
/**
14-
* Android plugin for Dart `BackgroundService`
15-
*
16-
* Receives messages/method calls from the foreground Dart side to manage
17-
* the background service, e.g. start (enqueue), stop (cancel)
28+
* Android plugin for Dart `BackgroundService` and file trash operations
1829
*/
19-
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
30+
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
2031

2132
private var methodChannel: MethodChannel? = null
33+
private var fileTrashChannel: MethodChannel? = null
2234
private var context: Context? = null
35+
private var pendingResult: Result? = null
36+
private val PERMISSION_REQUEST_CODE = 1001
37+
private var activityBinding: ActivityPluginBinding? = null
2338

2439
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
2540
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -29,6 +44,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
2944
context = ctx
3045
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
3146
methodChannel?.setMethodCallHandler(this)
47+
48+
// Add file trash channel
49+
fileTrashChannel = MethodChannel(messenger, "file_trash")
50+
fileTrashChannel?.setMethodCallHandler(this)
3251
}
3352

3453
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -38,11 +57,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
3857
private fun onDetachedFromEngine() {
3958
methodChannel?.setMethodCallHandler(null)
4059
methodChannel = null
60+
fileTrashChannel?.setMethodCallHandler(null)
61+
fileTrashChannel = null
4162
}
4263

43-
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
64+
override fun onMethodCall(call: MethodCall, result: Result) {
4465
val ctx = context!!
4566
when (call.method) {
67+
// Existing BackgroundService methods
4668
"enable" -> {
4769
val args = call.arguments<ArrayList<*>>()!!
4870
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@@ -114,10 +136,180 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
114136
}
115137
}
116138

139+
// File Trash methods moved from MainActivity
140+
"moveToTrash" -> {
141+
val fileName = call.argument<String>("fileName")
142+
if (fileName != null) {
143+
if (hasManageStoragePermission()) {
144+
val success = moveToTrash(fileName)
145+
result.success(success)
146+
} else {
147+
result.error("PERMISSION_DENIED", "Storage permission required", null)
148+
}
149+
} else {
150+
result.error("INVALID_NAME", "The file name is not specified.", null)
151+
}
152+
}
153+
154+
"restoreFromTrash" -> {
155+
val fileName = call.argument<String>("fileName")
156+
if (fileName != null) {
157+
if (hasManageStoragePermission()) {
158+
val success = untrashImage(fileName)
159+
result.success(success)
160+
} else {
161+
result.error("PERMISSION_DENIED", "Storage permission required", null)
162+
}
163+
} else {
164+
result.error("INVALID_NAME", "The file name is not specified.", null)
165+
}
166+
}
167+
168+
"requestManageStoragePermission" -> {
169+
if (!hasManageStoragePermission()) {
170+
requestManageStoragePermission(result)
171+
} else {
172+
Log.e("Manage storage permission", "Permission already granted")
173+
result.success(true)
174+
}
175+
}
176+
117177
else -> result.notImplemented()
118178
}
119179
}
180+
181+
// File Trash methods moved from MainActivity
182+
private fun hasManageStoragePermission(): Boolean {
183+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
184+
Environment.isExternalStorageManager()
185+
} else {
186+
true
187+
}
188+
}
189+
190+
private fun requestManageStoragePermission(result: Result) {
191+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
192+
pendingResult = result // Store the result callback
193+
val activity = activityBinding?.activity ?: return
194+
195+
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
196+
intent.data = Uri.parse("package:${activity.packageName}")
197+
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
198+
} else {
199+
result.success(true)
200+
}
201+
}
202+
203+
private fun moveToTrash(fileName: String): Boolean {
204+
val contentResolver = context?.contentResolver ?: return false
205+
val uri = getFileUri(fileName)
206+
Log.e("FILE_URI", uri.toString())
207+
return uri?.let { moveToTrash(it) } ?: false
208+
}
209+
210+
private fun moveToTrash(contentUri: Uri): Boolean {
211+
val contentResolver = context?.contentResolver ?: return false
212+
return try {
213+
val values = ContentValues().apply {
214+
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
215+
}
216+
val updated = contentResolver.update(contentUri, values, null, null)
217+
updated > 0
218+
} catch (e: Exception) {
219+
Log.e("TrashError", "Error moving to trash", e)
220+
false
221+
}
222+
}
223+
224+
private fun getFileUri(fileName: String): Uri? {
225+
val contentResolver = context?.contentResolver ?: return null
226+
val contentUri = MediaStore.Files.getContentUri("external")
227+
val projection = arrayOf(MediaStore.Images.Media._ID)
228+
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
229+
val selectionArgs = arrayOf(fileName)
230+
var fileUri: Uri? = null
231+
232+
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
233+
if (cursor.moveToFirst()) {
234+
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
235+
fileUri = ContentUris.withAppendedId(contentUri, id)
236+
}
237+
}
238+
return fileUri
239+
}
240+
241+
private fun untrashImage(name: String): Boolean {
242+
val contentResolver = context?.contentResolver ?: return false
243+
val uri = getTrashedFileUri(contentResolver, name)
244+
Log.e("FILE_URI", uri.toString())
245+
return uri?.let { untrashImage(it) } ?: false
246+
}
247+
248+
private fun untrashImage(contentUri: Uri): Boolean {
249+
val contentResolver = context?.contentResolver ?: return false
250+
return try {
251+
val values = ContentValues().apply {
252+
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
253+
}
254+
val updated = contentResolver.update(contentUri, values, null, null)
255+
updated > 0
256+
} catch (e: Exception) {
257+
Log.e("TrashError", "Error restoring file", e)
258+
false
259+
}
260+
}
261+
262+
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
263+
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
264+
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
265+
266+
val queryArgs = Bundle().apply {
267+
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?")
268+
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
269+
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
270+
}
271+
272+
contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor ->
273+
if (cursor.moveToFirst()) {
274+
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
275+
return ContentUris.withAppendedId(contentUri, id)
276+
}
277+
}
278+
return null
279+
}
280+
281+
// ActivityAware implementation
282+
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
283+
activityBinding = binding
284+
binding.addActivityResultListener(this)
285+
}
286+
287+
override fun onDetachedFromActivityForConfigChanges() {
288+
activityBinding?.removeActivityResultListener(this)
289+
activityBinding = null
290+
}
291+
292+
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
293+
activityBinding = binding
294+
binding.addActivityResultListener(this)
295+
}
296+
297+
override fun onDetachedFromActivity() {
298+
activityBinding?.removeActivityResultListener(this)
299+
activityBinding = null
300+
}
301+
302+
// ActivityResultListener implementation
303+
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
304+
if (requestCode == PERMISSION_REQUEST_CODE) {
305+
val granted = hasManageStoragePermission()
306+
pendingResult?.success(granted)
307+
pendingResult = null
308+
return true
309+
}
310+
return false
311+
}
120312
}
121313

122314
private const val TAG = "BackgroundServicePlugin"
123-
private const val BUFFER_SIZE = 2 * 1024 * 1024;
315+
private const val BUFFER_SIZE = 2 * 1024 * 1024

mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,12 @@ package app.alextran.immich
22

33
import io.flutter.embedding.android.FlutterActivity
44
import io.flutter.embedding.engine.FlutterEngine
5-
import android.os.Bundle
6-
import android.content.Intent
5+
import androidx.annotation.NonNull
76

87
class MainActivity : FlutterActivity() {
9-
10-
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
8+
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
119
super.configureFlutterEngine(flutterEngine)
1210
flutterEngine.plugins.add(BackgroundServicePlugin())
11+
// No need to set up method channel here as it's now handled in the plugin
1312
}
14-
1513
}

mobile/assets/i18n/en-US.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"advanced_settings_tile_title": "Advanced",
2424
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
2525
"advanced_settings_troubleshooting_title": "Troubleshooting",
26+
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
27+
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
2628
"album_info_card_backup_album_excluded": "EXCLUDED",
2729
"album_info_card_backup_album_included": "INCLUDED",
2830
"albums": "Albums",

mobile/lib/domain/models/store.model.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ enum StoreKey<T> {
6565

6666
// Video settings
6767
loadOriginalVideo<bool>._(136),
68+
manageLocalMediaAndroid<bool>._(137),
6869

6970
// Experimental stuff
7071
photoManagerCustomFilter<bool>._(1000);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
abstract interface class ILocalFilesManager {
2+
Future<bool> moveToTrash(String fileName);
3+
Future<bool> restoreFromTrash(String fileName);
4+
Future<bool> requestManageStoragePermission();
5+
}

mobile/lib/providers/websocket.provider.dart

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ enum PendingAction {
2323
assetDelete,
2424
assetUploaded,
2525
assetHidden,
26+
assetTrash,
2627
}
2728

2829
class PendingChange {
@@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
160161
socket.on('on_upload_success', _handleOnUploadSuccess);
161162
socket.on('on_config_update', _handleOnConfigUpdate);
162163
socket.on('on_asset_delete', _handleOnAssetDelete);
163-
socket.on('on_asset_trash', _handleServerUpdates);
164+
socket.on('on_asset_trash', _handleOnAssetTrash);
164165
socket.on('on_asset_restore', _handleServerUpdates);
165166
socket.on('on_asset_update', _handleServerUpdates);
166167
socket.on('on_asset_stack_update', _handleServerUpdates);
@@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
207208
_debounce.run(handlePendingChanges);
208209
}
209210

211+
Future<void> _handlePendingTrashes() async {
212+
final trashChanges = state.pendingChanges
213+
.where((c) => c.action == PendingAction.assetTrash)
214+
.toList();
215+
if (trashChanges.isNotEmpty) {
216+
List<String> remoteIds = trashChanges
217+
.expand((a) => (a.value as List).map((e) => e.toString()))
218+
.toList();
219+
220+
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
221+
await _ref.read(assetProvider.notifier).getAllAsset();
222+
223+
state = state.copyWith(
224+
pendingChanges: state.pendingChanges
225+
.whereNot((c) => trashChanges.contains(c))
226+
.toList(),
227+
);
228+
}
229+
}
230+
210231
Future<void> _handlePendingDeletes() async {
211232
final deleteChanges = state.pendingChanges
212233
.where((c) => c.action == PendingAction.assetDelete)
@@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
267288
await _handlePendingUploaded();
268289
await _handlePendingDeletes();
269290
await _handlingPendingHidden();
291+
await _handlePendingTrashes();
270292
}
271293

272294
void _handleOnConfigUpdate(dynamic _) {
@@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
285307
void _handleOnAssetDelete(dynamic data) =>
286308
addPendingChange(PendingAction.assetDelete, data);
287309

310+
void _handleOnAssetTrash(dynamic data) {
311+
addPendingChange(PendingAction.assetTrash, data);
312+
}
313+
288314
void _handleOnAssetHidden(dynamic data) =>
289315
addPendingChange(PendingAction.assetHidden, data);
290316

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import 'package:hooks_riverpod/hooks_riverpod.dart';
2+
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
3+
import 'package:immich_mobile/utils/local_files_manager.dart';
4+
5+
final localFilesManagerRepositoryProvider =
6+
Provider((ref) => LocalFilesManagerRepository());
7+
8+
class LocalFilesManagerRepository implements ILocalFilesManager {
9+
@override
10+
Future<bool> moveToTrash(String fileName) async {
11+
return await LocalFilesManager.moveToTrash(fileName);
12+
}
13+
14+
@override
15+
Future<bool> restoreFromTrash(String fileName) async {
16+
return await LocalFilesManager.restoreFromTrash(fileName);
17+
}
18+
19+
@override
20+
Future<bool> requestManageStoragePermission() async {
21+
return await LocalFilesManager.requestManageStoragePermission();
22+
}
23+
}

0 commit comments

Comments
 (0)