11package app.alextran.immich
22
3+ import android.app.Activity
4+ import android.content.ContentResolver
5+ import android.content.ContentUris
36import 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
413import android.util.Log
14+ import androidx.annotation.RequiresApi
515import io.flutter.embedding.engine.plugins.FlutterPlugin
16+ import io.flutter.embedding.engine.plugins.activity.ActivityAware
17+ import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
618import io.flutter.plugin.common.BinaryMessenger
719import io.flutter.plugin.common.MethodCall
820import io.flutter.plugin.common.MethodChannel
21+ import io.flutter.plugin.common.MethodChannel.Result
22+ import io.flutter.plugin.common.PluginRegistry
923import java.security.MessageDigest
1024import java.io.FileInputStream
1125import 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
122320private const val TAG = " BackgroundServicePlugin"
123- private const val BUFFER_SIZE = 2 * 1024 * 1024 ;
321+ private const val BUFFER_SIZE = 2 * 1024 * 1024
0 commit comments