Skip to content

Commit 0511530

Browse files
shenlong-tanwensavely-krasovsky
authored andcommitted
fix(mobile): auto trash using MANAGE_MEDIA (immich-app#17828)
fix: auto trash using MANAGE_MEDIA Co-authored-by: shenlong-tanwen <[email protected]>
1 parent c87bcf9 commit 0511530

File tree

13 files changed

+420
-15
lines changed

13 files changed

+420
-15
lines changed

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

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

3+
import android.app.Activity
4+
import android.content.ContentResolver
5+
import android.content.ContentUris
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.provider.MediaStore
12+
import android.provider.Settings
413
import android.util.Log
14+
import androidx.annotation.RequiresApi
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.*
26+
import androidx.core.net.toUri
1227

1328
/**
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)
29+
* Android plugin for Dart `BackgroundService` and file trash operations
1830
*/
19-
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
31+
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
2032

2133
private var methodChannel: MethodChannel? = null
34+
private var fileTrashChannel: MethodChannel? = null
2235
private var context: Context? = null
36+
private var pendingResult: Result? = null
37+
private val permissionRequestCode = 1001
38+
private val trashRequestCode = 1002
39+
private var activityBinding: ActivityPluginBinding? = null
2340

2441
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
2542
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
2946
context = ctx
3047
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
3148
methodChannel?.setMethodCallHandler(this)
49+
50+
// Add file trash channel
51+
fileTrashChannel = MethodChannel(messenger, "file_trash")
52+
fileTrashChannel?.setMethodCallHandler(this)
3253
}
3354

3455
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
3859
private fun onDetachedFromEngine() {
3960
methodChannel?.setMethodCallHandler(null)
4061
methodChannel = null
62+
fileTrashChannel?.setMethodCallHandler(null)
63+
fileTrashChannel = null
4164
}
4265

43-
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
66+
override fun onMethodCall(call: MethodCall, result: Result) {
4467
val ctx = context!!
4568
when (call.method) {
69+
// Existing BackgroundService methods
4670
"enable" -> {
4771
val args = call.arguments<ArrayList<*>>()!!
4872
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@@ -114,10 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
114138
}
115139
}
116140

141+
// File Trash methods moved from MainActivity
142+
"moveToTrash" -> {
143+
val mediaUrls = call.argument<List<String>>("mediaUrls")
144+
if (mediaUrls != null) {
145+
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
146+
moveToTrash(mediaUrls, result)
147+
} else {
148+
result.error("PERMISSION_DENIED", "Media permission required", null)
149+
}
150+
} else {
151+
result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
152+
}
153+
}
154+
155+
"restoreFromTrash" -> {
156+
val fileName = call.argument<String>("fileName")
157+
val type = call.argument<Int>("type")
158+
if (fileName != null && type != null) {
159+
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
160+
restoreFromTrash(fileName, type, result)
161+
} else {
162+
result.error("PERMISSION_DENIED", "Media permission required", null)
163+
}
164+
} else {
165+
result.error("INVALID_NAME", "The file name is not specified.", null)
166+
}
167+
}
168+
169+
"requestManageMediaPermission" -> {
170+
if (!hasManageMediaPermission()) {
171+
requestManageMediaPermission(result)
172+
} else {
173+
Log.e("Manage storage permission", "Permission already granted")
174+
result.success(true)
175+
}
176+
}
177+
117178
else -> result.notImplemented()
118179
}
119180
}
181+
182+
private fun hasManageMediaPermission(): Boolean {
183+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
184+
MediaStore.canManageMedia(context!!);
185+
} else {
186+
false
187+
}
188+
}
189+
190+
private fun requestManageMediaPermission(result: Result) {
191+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
192+
pendingResult = result // Store the result callback
193+
val activity = activityBinding?.activity ?: return
194+
195+
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
196+
intent.data = "package:${activity.packageName}".toUri()
197+
activity.startActivityForResult(intent, permissionRequestCode)
198+
} else {
199+
result.success(false)
200+
}
201+
}
202+
203+
@RequiresApi(Build.VERSION_CODES.R)
204+
private fun moveToTrash(mediaUrls: List<String>, result: Result) {
205+
val urisToTrash = mediaUrls.map { it.toUri() }
206+
if (urisToTrash.isEmpty()) {
207+
result.error("INVALID_ARGS", "No valid URIs provided", null)
208+
return
209+
}
210+
211+
toggleTrash(urisToTrash, true, result);
212+
}
213+
214+
@RequiresApi(Build.VERSION_CODES.R)
215+
private fun restoreFromTrash(name: String, type: Int, result: Result) {
216+
val uri = getTrashedFileUri(name, type)
217+
if (uri == null) {
218+
Log.e("TrashError", "Asset Uri cannot be found obtained")
219+
result.error("TrashError", "Asset Uri cannot be found obtained", null)
220+
return
221+
}
222+
Log.e("FILE_URI", uri.toString())
223+
uri.let { toggleTrash(listOf(it), false, result) }
224+
}
225+
226+
@RequiresApi(Build.VERSION_CODES.R)
227+
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
228+
val activity = activityBinding?.activity
229+
val contentResolver = context?.contentResolver
230+
if (activity == null || contentResolver == null) {
231+
result.error("TrashError", "Activity or ContentResolver not available", null)
232+
return
233+
}
234+
235+
try {
236+
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
237+
pendingResult = result // Store for onActivityResult
238+
activity.startIntentSenderForResult(
239+
pendingIntent.intentSender,
240+
trashRequestCode,
241+
null, 0, 0, 0
242+
)
243+
} catch (e: Exception) {
244+
Log.e("TrashError", "Error creating or starting trash request", e)
245+
result.error("TrashError", "Error creating or starting trash request", null)
246+
}
247+
}
248+
249+
@RequiresApi(Build.VERSION_CODES.R)
250+
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
251+
val contentResolver = context?.contentResolver ?: return null
252+
val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
253+
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
254+
255+
val queryArgs = Bundle().apply {
256+
putString(
257+
ContentResolver.QUERY_ARG_SQL_SELECTION,
258+
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
259+
)
260+
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
261+
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
262+
}
263+
264+
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
265+
if (cursor.moveToFirst()) {
266+
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
267+
// same order as AssetType from dart
268+
val contentUri = when (type) {
269+
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
270+
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
271+
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
272+
else -> queryUri
273+
}
274+
return ContentUris.withAppendedId(contentUri, id)
275+
}
276+
}
277+
return null
278+
}
279+
280+
// ActivityAware implementation
281+
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
282+
activityBinding = binding
283+
binding.addActivityResultListener(this)
284+
}
285+
286+
override fun onDetachedFromActivityForConfigChanges() {
287+
activityBinding?.removeActivityResultListener(this)
288+
activityBinding = null
289+
}
290+
291+
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
292+
activityBinding = binding
293+
binding.addActivityResultListener(this)
294+
}
295+
296+
override fun onDetachedFromActivity() {
297+
activityBinding?.removeActivityResultListener(this)
298+
activityBinding = null
299+
}
300+
301+
// ActivityResultListener implementation
302+
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
303+
if (requestCode == permissionRequestCode) {
304+
val granted = hasManageMediaPermission()
305+
pendingResult?.success(granted)
306+
pendingResult = null
307+
return true
308+
}
309+
310+
if (requestCode == trashRequestCode) {
311+
val approved = resultCode == Activity.RESULT_OK
312+
pendingResult?.success(approved)
313+
pendingResult = null
314+
return true
315+
}
316+
return false
317+
}
120318
}
121319

122320
private const val TAG = "BackgroundServicePlugin"
123-
private const val BUFFER_SIZE = 2 * 1024 * 1024;
321+
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/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(List<String> mediaUrls);
3+
Future<bool> restoreFromTrash(String fileName, int type);
4+
Future<bool> requestManageMediaPermission();
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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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) => const LocalFilesManagerRepository());
7+
8+
class LocalFilesManagerRepository implements ILocalFilesManager {
9+
const LocalFilesManagerRepository();
10+
11+
@override
12+
Future<bool> moveToTrash(List<String> mediaUrls) async {
13+
return await LocalFilesManager.moveToTrash(mediaUrls);
14+
}
15+
16+
@override
17+
Future<bool> restoreFromTrash(String fileName, int type) async {
18+
return await LocalFilesManager.restoreFromTrash(fileName, type);
19+
}
20+
21+
@override
22+
Future<bool> requestManageMediaPermission() async {
23+
return await LocalFilesManager.requestManageMediaPermission();
24+
}
25+
}

mobile/lib/services/app_settings.service.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
6161
0,
6262
),
6363
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
64+
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
6465
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
6566
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
6667
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),

0 commit comments

Comments
 (0)