Skip to content

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

Merged
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2d24c9e
Features: Local file movement to trash and restoration back to the al…
aleksandrsovtan Mar 8, 2025
18d9d63
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 9, 2025
905e8fb
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 10, 2025
b65b88d
Comments fixes
aleksandrsovtan Mar 10, 2025
386f767
settings button marked as [EXPERIMENTAL]
aleksandrsovtan Mar 10, 2025
2616587
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 11, 2025
638785c
_moveToTrashMatchedAssets refactored, moveToTrash renamed.
aleksandrsovtan Mar 11, 2025
bc5fa20
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 11, 2025
084aa53
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 12, 2025
d083649
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 14, 2025
ddd7eae
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 14, 2025
94104cf
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 18, 2025
b907e36
Merge branch 'main' into mobile/manage_local_media_files
alextran1502 Mar 18, 2025
68ed09d
fix: bad merge
alextran1502 Mar 18, 2025
5802822
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 18, 2025
8039f9f
Permission check and request for local storage added.
aleksandrsovtan Mar 21, 2025
aafe29d
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 21, 2025
c29004c
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 21, 2025
dbbdfce
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 22, 2025
41afc84
Permission request added on settings switcher
aleksandrsovtan Mar 22, 2025
f12d211
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 25, 2025
fca7a32
Settings button logic changed
aleksandrsovtan Mar 27, 2025
91eb052
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 27, 2025
7719d39
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 28, 2025
6973b0e
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Mar 30, 2025
789d173
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Apr 3, 2025
5ec71c9
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Apr 4, 2025
2e71c85
Method channel file_trash moved to BackgroundServicePlugin
aleksandrsovtan Apr 4, 2025
efdbd6b
Merge branch 'main' into mobile/manage_local_media_files
aleksandrsovtan Apr 7, 2025
eceb586
Merge branch 'main' into mobile/manage_local_media_files
alextran1502 Apr 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down Expand Up @@ -124,4 +125,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you re-use the existing method channel in BackgroundServicePlugin?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aleksandrsovtan Can you resolve this comment to proceed with the review?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @aleksandrsovtan, Can you help resolve this comment, moving the code to BackgroundServicePlugin so that we have better separation? I think after this resolved, we can emerge the PR. I just tested again and everything looks good

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
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
}
}
2 changes: 2 additions & 0 deletions mobile/assets/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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",
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/domain/models/store.model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ enum StoreKey<T> {

// Video settings
loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
;

const StoreKey._(this.id);
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/interfaces/local_files_manager.interface.dart
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();
}
28 changes: 27 additions & 1 deletion mobile/lib/providers/websocket.provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
assetTrash,
}

class PendingChange {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
await _handlePendingTrashes();
}

void _handleOnConfigUpdate(dynamic _) {
Expand All @@ -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);

Expand Down
23 changes: 23 additions & 0 deletions mobile/lib/repositories/local_files_manager.repository.dart
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();
}
}
1 change: 1 addition & 0 deletions mobile/lib/services/app_settings.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading