diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index eb81dc267b..58d7f0655a 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
android:maxSdkVersion="32" />
+
@@ -124,4 +125,4 @@
-
\ No newline at end of file
+
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
index 8520413cff..e7f787e8d8 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt
@@ -1,25 +1,40 @@
package app.alextran.immich
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.ContentValues
import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.provider.MediaStore
+import android.provider.Settings
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.embedding.engine.plugins.activity.ActivityAware
+import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.Result
+import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest
import java.io.FileInputStream
import kotlinx.coroutines.*
/**
- * Android plugin for Dart `BackgroundService`
- *
- * Receives messages/method calls from the foreground Dart side to manage
- * the background service, e.g. start (enqueue), stop (cancel)
+ * Android plugin for Dart `BackgroundService` and file trash operations
*/
-class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
+class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
private var methodChannel: MethodChannel? = null
+ private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null
+ private var pendingResult: Result? = null
+ private val PERMISSION_REQUEST_CODE = 1001
+ private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -29,6 +44,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
+
+ // Add file trash channel
+ fileTrashChannel = MethodChannel(messenger, "file_trash")
+ fileTrashChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -38,11 +57,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
+ fileTrashChannel?.setMethodCallHandler(null)
+ fileTrashChannel = null
}
- override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ override fun onMethodCall(call: MethodCall, result: Result) {
val ctx = context!!
when (call.method) {
+ // Existing BackgroundService methods
"enable" -> {
val args = call.arguments>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@@ -114,10 +136,180 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
+ // File Trash methods moved from MainActivity
+ "moveToTrash" -> {
+ val fileName = call.argument("fileName")
+ if (fileName != null) {
+ if (hasManageStoragePermission()) {
+ val success = moveToTrash(fileName)
+ result.success(success)
+ } else {
+ 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("fileName")
+ if (fileName != null) {
+ if (hasManageStoragePermission()) {
+ val success = untrashImage(fileName)
+ result.success(success)
+ } else {
+ result.error("PERMISSION_DENIED", "Storage permission required", null)
+ }
+ } else {
+ result.error("INVALID_NAME", "The file name is not specified.", null)
+ }
+ }
+
+ "requestManageStoragePermission" -> {
+ if (!hasManageStoragePermission()) {
+ requestManageStoragePermission(result)
+ } else {
+ Log.e("Manage storage permission", "Permission already granted")
+ result.success(true)
+ }
+ }
+
else -> result.notImplemented()
}
}
+
+ // File Trash methods moved from MainActivity
+ private fun hasManageStoragePermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Environment.isExternalStorageManager()
+ } else {
+ true
+ }
+ }
+
+ private fun requestManageStoragePermission(result: Result) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ pendingResult = result // Store the result callback
+ val activity = activityBinding?.activity ?: return
+
+ val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
+ intent.data = Uri.parse("package:${activity.packageName}")
+ activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
+ } else {
+ result.success(true)
+ }
+ }
+
+ private fun moveToTrash(fileName: String): Boolean {
+ val contentResolver = context?.contentResolver ?: return false
+ val uri = getFileUri(fileName)
+ Log.e("FILE_URI", uri.toString())
+ return uri?.let { moveToTrash(it) } ?: false
+ }
+
+ private fun moveToTrash(contentUri: Uri): Boolean {
+ val contentResolver = context?.contentResolver ?: return false
+ 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 contentResolver = context?.contentResolver ?: return null
+ 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 contentResolver = context?.contentResolver ?: return false
+ val uri = getTrashedFileUri(contentResolver, name)
+ Log.e("FILE_URI", uri.toString())
+ return uri?.let { untrashImage(it) } ?: false
+ }
+
+ private fun untrashImage(contentUri: Uri): Boolean {
+ val contentResolver = context?.contentResolver ?: return false
+ 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
+ }
+ }
+
+ private 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
+ }
+
+ // ActivityAware implementation
+ override fun onAttachedToActivity(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ binding.addActivityResultListener(this)
+ }
+
+ override fun onDetachedFromActivityForConfigChanges() {
+ activityBinding?.removeActivityResultListener(this)
+ activityBinding = null
+ }
+
+ override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
+ activityBinding = binding
+ binding.addActivityResultListener(this)
+ }
+
+ override fun onDetachedFromActivity() {
+ activityBinding?.removeActivityResultListener(this)
+ activityBinding = null
+ }
+
+ // ActivityResultListener implementation
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
+ if (requestCode == PERMISSION_REQUEST_CODE) {
+ val granted = hasManageStoragePermission()
+ pendingResult?.success(granted)
+ pendingResult = null
+ return true
+ }
+ return false
+ }
}
private const val TAG = "BackgroundServicePlugin"
-private const val BUFFER_SIZE = 2 * 1024 * 1024;
+private const val BUFFER_SIZE = 2 * 1024 * 1024
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index 4ffb490c77..2b6bf81148 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -2,14 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
-import android.os.Bundle
-import android.content.Intent
+import androidx.annotation.NonNull
class MainActivity : FlutterActivity() {
-
- override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
+ // No need to set up method channel here as it's now handled in the plugin
}
-
}
diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json
index e3b6916e74..3aa2f1b475 100644
--- a/mobile/assets/i18n/en-US.json
+++ b/mobile/assets/i18n/en-US.json
@@ -23,6 +23,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",
diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart
index e6d9ecaf48..8a5a908e0d 100644
--- a/mobile/lib/domain/models/store.model.dart
+++ b/mobile/lib/domain/models/store.model.dart
@@ -65,6 +65,7 @@ enum StoreKey {
// Video settings
loadOriginalVideo._(136),
+ manageLocalMediaAndroid._(137),
// Experimental stuff
photoManagerCustomFilter._(1000);
diff --git a/mobile/lib/interfaces/local_files_manager.interface.dart b/mobile/lib/interfaces/local_files_manager.interface.dart
new file mode 100644
index 0000000000..c8b83a7c93
--- /dev/null
+++ b/mobile/lib/interfaces/local_files_manager.interface.dart
@@ -0,0 +1,5 @@
+abstract interface class ILocalFilesManager {
+ Future moveToTrash(String fileName);
+ Future restoreFromTrash(String fileName);
+ Future requestManageStoragePermission();
+}
diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart
index f92d2c8421..72dbda8b6f 100644
--- a/mobile/lib/providers/websocket.provider.dart
+++ b/mobile/lib/providers/websocket.provider.dart
@@ -23,6 +23,7 @@ enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
+ assetTrash,
}
class PendingChange {
@@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier {
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);
@@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier {
_debounce.run(handlePendingChanges);
}
+ Future _handlePendingTrashes() async {
+ final trashChanges = state.pendingChanges
+ .where((c) => c.action == PendingAction.assetTrash)
+ .toList();
+ if (trashChanges.isNotEmpty) {
+ List 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 _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
@@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
+ await _handlePendingTrashes();
}
void _handleOnConfigUpdate(dynamic _) {
@@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier {
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
+ void _handleOnAssetTrash(dynamic data) {
+ addPendingChange(PendingAction.assetTrash, data);
+ }
+
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);
diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart
new file mode 100644
index 0000000000..522d7e7a05
--- /dev/null
+++ b/mobile/lib/repositories/local_files_manager.repository.dart
@@ -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 moveToTrash(String fileName) async {
+ return await LocalFilesManager.moveToTrash(fileName);
+ }
+
+ @override
+ Future restoreFromTrash(String fileName) async {
+ return await LocalFilesManager.restoreFromTrash(fileName);
+ }
+
+ @override
+ Future requestManageStoragePermission() async {
+ return await LocalFilesManager.requestManageStoragePermission();
+ }
+}
diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart
index cc57b8d3a3..6413b69fce 100644
--- a/mobile/lib/services/app_settings.service.dart
+++ b/mobile/lib/services/app_settings.service.dart
@@ -61,6 +61,7 @@ enum AppSettingsEnum {
0,
),
advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false),
+ manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false),
logLevel(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage(StoreKey.preferRemoteImage, null, false),
loopVideo(StoreKey.loopVideo, "loopVideo", true),
diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart
index 1e3c2a070b..0574dc283b 100644
--- a/mobile/lib/services/sync.service.dart
+++ b/mobile/lib/services/sync.service.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -16,6 +17,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
+import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
+import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
@@ -25,6 +28,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
+import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/entity.service.dart';
@@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider),
+ ref.watch(appSettingsServiceProvider),
+ ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider),
),
@@ -69,6 +76,8 @@ class SyncService {
final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
+ final AppSettingsService _appSettingsService;
+ final ILocalFilesManager _localFilesManager;
SyncService(
this._hashService,
@@ -82,6 +91,8 @@ class SyncService {
this._userRepository,
this._userService,
this._eTagRepository,
+ this._appSettingsService,
+ this._localFilesManager,
this._partnerApiRepository,
this._userApiRepository,
);
@@ -238,8 +249,19 @@ class SyncService {
return null;
}
+ Future _moveToTrashMatchedAssets(Iterable idsToDelete) async {
+ final List localAssets = await _assetRepository.getAllLocal();
+ final List matchedAssets = localAssets
+ .where((asset) => idsToDelete.contains(asset.remoteId))
+ .toList();
+
+ for (var asset in matchedAssets) {
+ _localFilesManager.moveToTrash(asset.fileName);
+ }
+ }
+
/// Deletes remote-only assets, updates merged assets to be local-only
- Future handleRemoteAssetRemoval(List idsToDelete) {
+ Future handleRemoteAssetRemoval(List idsToDelete) async {
return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId(
idsToDelete,
@@ -249,6 +271,12 @@ class SyncService {
idsToDelete,
state: AssetState.merged,
);
+ if (Platform.isAndroid &&
+ _appSettingsService.getSetting(
+ AppSettingsEnum.manageLocalMediaAndroid,
+ )) {
+ await _moveToTrashMatchedAssets(idsToDelete);
+ }
if (merged.isEmpty) return;
for (final Asset asset in merged) {
asset.remoteId = null;
@@ -790,9 +818,27 @@ class SyncService {
return (existing, toUpsert);
}
+ Future _toggleTrashStatusForAssets(List assetsList) async {
+ for (var asset in assetsList) {
+ if (asset.isTrashed) {
+ _localFilesManager.moveToTrash(asset.fileName);
+ } else {
+ _localFilesManager.restoreFromTrash(asset.fileName);
+ }
+ }
+ }
+
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future upsertAssetsWithExif(List assets) async {
if (assets.isEmpty) return;
+
+ if (Platform.isAndroid &&
+ _appSettingsService.getSetting(
+ AppSettingsEnum.manageLocalMediaAndroid,
+ )) {
+ _toggleTrashStatusForAssets(assets);
+ }
+
final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList();
try {
await _assetRepository.transaction(() async {
diff --git a/mobile/lib/utils/local_files_manager.dart b/mobile/lib/utils/local_files_manager.dart
new file mode 100644
index 0000000000..da9308c3cf
--- /dev/null
+++ b/mobile/lib/utils/local_files_manager.dart
@@ -0,0 +1,39 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+class LocalFilesManager {
+ static const MethodChannel _channel = MethodChannel('file_trash');
+
+ static Future moveToTrash(String fileName) async {
+ try {
+ final bool success =
+ await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
+ return success;
+ } on PlatformException catch (e) {
+ debugPrint('Error moving to trash: ${e.message}');
+ return false;
+ }
+ }
+
+ static Future restoreFromTrash(String fileName) async {
+ try {
+ final bool success = await _channel
+ .invokeMethod('restoreFromTrash', {'fileName': fileName});
+ return success;
+ } on PlatformException catch (e) {
+ debugPrint('Error restoring file: ${e.message}');
+ return false;
+ }
+ }
+
+ static Future requestManageStoragePermission() async {
+ try {
+ final bool success =
+ await _channel.invokeMethod('requestManageStoragePermission');
+ return success;
+ } on PlatformException catch (e) {
+ debugPrint('Error requesting permission: ${e.message}');
+ return false;
+ }
+ }
+}
diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart
index a2e0e5b95c..98c8728298 100644
--- a/mobile/lib/widgets/settings/advanced_settings.dart
+++ b/mobile/lib/widgets/settings/advanced_settings.dart
@@ -1,11 +1,13 @@
import 'dart:io';
+import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
+import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
+ final manageLocalMediaAndroid =
+ useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert =
@@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
);
+ Future checkAndroidVersion() async {
+ if (Platform.isAndroid) {
+ DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
+ AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
+ int sdkVersion = androidInfo.version.sdkInt;
+ return sdkVersion >= 30;
+ }
+ return false;
+ }
+
final advancedSettings = [
SettingsSwitchListTile(
enabled: true,
@@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
+ FutureBuilder(
+ future: checkAndroidVersion(),
+ builder: (context, snapshot) {
+ if (snapshot.hasData && snapshot.data == true) {
+ return SettingsSwitchListTile(
+ enabled: true,
+ valueNotifier: manageLocalMediaAndroid,
+ title: "advanced_settings_sync_remote_deletions_title".tr(),
+ subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
+ onChanged: (value) async {
+ if (value) {
+ final result = await ref
+ .read(localFilesManagerRepositoryProvider)
+ .requestManageStoragePermission();
+ manageLocalMediaAndroid.value = result;
+ }
+ },
+ );
+ } else {
+ return const SizedBox.shrink();
+ }
+ },
+ ),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId,
diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart
index eab6b6f61a..47bc1b9544 100644
--- a/mobile/test/modules/shared/sync_service_test.dart
+++ b/mobile/test/modules/shared/sync_service_test.dart
@@ -60,6 +60,9 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
+ final MockAppSettingService appSettingService = MockAppSettingService();
+ final MockLocalFilesManagerRepository localFilesManagerRepository =
+ MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository();
@@ -106,6 +109,8 @@ void main() {
userRepository,
userService,
eTagRepository,
+ appSettingService,
+ localFilesManagerRepository,
partnerApiRepository,
userApiRepository,
);
diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart
index 1c698297dc..d2f0da4231 100644
--- a/mobile/test/repository.mocks.dart
+++ b/mobile/test/repository.mocks.dart
@@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
+import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart';
@@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {}
+class MockPartnerRepository extends Mock implements IPartnerRepository {}
+
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
-class MockPartnerRepository extends Mock implements IPartnerRepository {}
+class MockLocalFilesManagerRepository extends Mock
+ implements ILocalFilesManager {}
diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart
index d31a7e5d50..e1b8df40a3 100644
--- a/mobile/test/service.mocks.dart
+++ b/mobile/test/service.mocks.dart
@@ -1,5 +1,6 @@
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
@@ -25,4 +26,7 @@ class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {}
+class MockAppSettingService extends Mock implements AppSettingsService {}
+
class MockBackgroundService extends Mock implements BackgroundService {}
+