-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat: opt-in sync of deletes and restores from web to Android #16732
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 21 commits
2d24c9e
18d9d63
905e8fb
b65b88d
386f767
2616587
638785c
bc5fa20
084aa53
d083649
ddd7eae
94104cf
b907e36
68ed09d
5802822
8039f9f
aafe29d
c29004c
dbbdfce
41afc84
f12d211
fca7a32
91eb052
7719d39
6973b0e
789d173
5ec71c9
2e71c85
efdbd6b
eceb586
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,13 +3,162 @@ package app.alextran.immich | |
import io.flutter.embedding.android.FlutterActivity | ||
import io.flutter.embedding.engine.FlutterEngine | ||
import android.os.Bundle | ||
import android.content.ContentUris | ||
import android.content.ContentValues | ||
import android.content.ContentResolver | ||
import android.net.Uri | ||
import android.provider.MediaStore | ||
import android.util.Log | ||
import android.os.Build | ||
import android.os.Environment | ||
import android.provider.Settings | ||
import android.content.Intent | ||
import android.content.Context | ||
import androidx.annotation.NonNull | ||
import io.flutter.plugin.common.MethodCall | ||
import io.flutter.plugin.common.MethodChannel | ||
|
||
class MainActivity : FlutterActivity() { | ||
private val CHANNEL = "file_trash" | ||
private val REQUEST_MANAGE_STORAGE = 1001 | ||
private val REQUEST_TRASH_FILE = 1002 | ||
|
||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { | ||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { | ||
super.configureFlutterEngine(flutterEngine) | ||
flutterEngine.plugins.add(BackgroundServicePlugin()) | ||
|
||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> | ||
when (call.method) { | ||
"moveToTrash" -> { | ||
val fileName = call.argument<String>("fileName") | ||
if (fileName != null) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you re-use the existing method channel in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @aleksandrsovtan Can you resolve this comment to proceed with the review? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @aleksandrsovtan, Can you help resolve this comment, moving the code to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This might be a challenge, since the background service plugin doesn't have an activity. I'll try to do it ASAP There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @shenlong-tanwen Done! |
||
if (hasManageStoragePermission()) { | ||
val success = moveToTrash(fileName) | ||
result.success(success) | ||
} else { | ||
requestManageStoragePermission() | ||
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 { | ||
requestManageStoragePermission() | ||
result.error("PERMISSION_DENIED", "Storage permission required", null) | ||
} | ||
} else { | ||
result.error("INVALID_NAME", "The file name is not specified.", null) | ||
} | ||
} | ||
"requestManageStoragePermission" -> { | ||
requestManageStoragePermission() | ||
result.success(true) | ||
} | ||
else -> result.notImplemented() | ||
} | ||
} | ||
} | ||
|
||
private fun hasManageStoragePermission(): Boolean { | ||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { | ||
Environment.isExternalStorageManager() | ||
} else { | ||
true | ||
} | ||
} | ||
|
||
private fun requestManageStoragePermission() { | ||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { | ||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) | ||
intent.data = Uri.parse("package:$packageName") | ||
startActivity(intent) | ||
} | ||
} | ||
|
||
private fun moveToTrash(fileName: String): Boolean { | ||
val uri = getFileUri(fileName) | ||
|
||
Log.e("FILE_URI", uri.toString()) | ||
|
||
return uri?.let { moveToTrash(it) } ?: false | ||
} | ||
|
||
private fun moveToTrash(contentUri: Uri): Boolean { | ||
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 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 uri = getTrashedFileUri(contentResolver,name) | ||
Log.e("FILE_URI", uri.toString()) | ||
|
||
return uri?.let { untrashImage(it) } ?: false | ||
} | ||
|
||
|
||
private fun untrashImage(contentUri: Uri): Boolean { | ||
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 | ||
} | ||
} | ||
|
||
fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? { | ||
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) | ||
shenlong-tanwen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
alextran1502 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
abstract interface class ILocalFilesManager { | ||
Future<bool> moveToTrash(String fileName); | ||
Future<bool> restoreFromTrash(String fileName); | ||
Future<bool> requestManageStoragePermission(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.