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..ae2ec22a71 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,42 @@ package app.alextran.immich +import android.app.Activity +import android.content.ContentResolver +import android.content.ContentUris import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.provider.Settings import android.util.Log +import androidx.annotation.RequiresApi 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.* +import androidx.core.net.toUri /** - * 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 permissionRequestCode = 1001 + private val trashRequestCode = 1002 + private var activityBinding: ActivityPluginBinding? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) @@ -29,6 +46,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 +59,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 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } } + // File Trash methods moved from MainActivity + "moveToTrash" -> { + val mediaUrls = call.argument>("mediaUrls") + if (mediaUrls != null) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { + moveToTrash(mediaUrls, result) + } else { + result.error("PERMISSION_DENIED", "Media permission required", null) + } + } else { + result.error("INVALID_NAME", "The mediaUrls is not specified.", null) + } + } + + "restoreFromTrash" -> { + val fileName = call.argument("fileName") + val type = call.argument("type") + if (fileName != null && type != null) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { + restoreFromTrash(fileName, type, result) + } else { + result.error("PERMISSION_DENIED", "Media permission required", null) + } + } else { + result.error("INVALID_NAME", "The file name is not specified.", null) + } + } + + "requestManageMediaPermission" -> { + if (!hasManageMediaPermission()) { + requestManageMediaPermission(result) + } else { + Log.e("Manage storage permission", "Permission already granted") + result.success(true) + } + } + else -> result.notImplemented() } } + + private fun hasManageMediaPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaStore.canManageMedia(context!!); + } else { + false + } + } + + private fun requestManageMediaPermission(result: Result) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + pendingResult = result // Store the result callback + val activity = activityBinding?.activity ?: return + + val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA) + intent.data = "package:${activity.packageName}".toUri() + activity.startActivityForResult(intent, permissionRequestCode) + } else { + result.success(false) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun moveToTrash(mediaUrls: List, result: Result) { + val urisToTrash = mediaUrls.map { it.toUri() } + if (urisToTrash.isEmpty()) { + result.error("INVALID_ARGS", "No valid URIs provided", null) + return + } + + toggleTrash(urisToTrash, true, result); + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun restoreFromTrash(name: String, type: Int, result: Result) { + val uri = getTrashedFileUri(name, type) + if (uri == null) { + Log.e("TrashError", "Asset Uri cannot be found obtained") + result.error("TrashError", "Asset Uri cannot be found obtained", null) + return + } + Log.e("FILE_URI", uri.toString()) + uri.let { toggleTrash(listOf(it), false, result) } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) { + val activity = activityBinding?.activity + val contentResolver = context?.contentResolver + if (activity == null || contentResolver == null) { + result.error("TrashError", "Activity or ContentResolver not available", null) + return + } + + try { + val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) + pendingResult = result // Store for onActivityResult + activity.startIntentSenderForResult( + pendingIntent.intentSender, + trashRequestCode, + null, 0, 0, 0 + ) + } catch (e: Exception) { + Log.e("TrashError", "Error creating or starting trash request", e) + result.error("TrashError", "Error creating or starting trash request", null) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getTrashedFileUri(fileName: String, type: Int): Uri? { + val contentResolver = context?.contentResolver ?: return null + val queryUri = 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(queryUri, projection, queryArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) + // same order as AssetType from dart + val contentUri = when (type) { + 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> queryUri + } + 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 == permissionRequestCode) { + val granted = hasManageMediaPermission() + pendingResult?.success(granted) + pendingResult = null + return true + } + + if (requestCode == trashRequestCode) { + val approved = resultCode == Activity.RESULT_OK + pendingResult?.success(approved) + 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/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..07274b7e29 --- /dev/null +++ b/mobile/lib/interfaces/local_files_manager.interface.dart @@ -0,0 +1,5 @@ +abstract interface class ILocalFilesManager { + Future moveToTrash(List mediaUrls); + Future restoreFromTrash(String fileName, int type); + Future requestManageMediaPermission(); +} 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..c2e234d14d --- /dev/null +++ b/mobile/lib/repositories/local_files_manager.repository.dart @@ -0,0 +1,25 @@ +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) => const LocalFilesManagerRepository()); + +class LocalFilesManagerRepository implements ILocalFilesManager { + const LocalFilesManagerRepository(); + + @override + Future moveToTrash(List mediaUrls) async { + return await LocalFilesManager.moveToTrash(mediaUrls); + } + + @override + Future restoreFromTrash(String fileName, int type) async { + return await LocalFilesManager.restoreFromTrash(fileName, type); + } + + @override + Future requestManageMediaPermission() async { + return await LocalFilesManager.requestManageMediaPermission(); + } +} 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 11a9dcb56a..80950d8c00 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,8 +17,10 @@ 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/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; @@ -25,8 +28,10 @@ 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/repositories/partner.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.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,22 @@ 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(); + + final mediaUrls = await Future.wait( + matchedAssets + .map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)), + ); + + await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); + } + /// 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 +274,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,10 +821,43 @@ class SyncService { return (existing, toUpsert); } + Future _toggleTrashStatusForAssets(List assetsList) async { + final trashMediaUrls = []; + + for (final asset in assetsList) { + if (asset.isTrashed) { + final mediaUrl = await asset.local?.getMediaUrl(); + if (mediaUrl == null) { + _log.warning( + "Failed to get media URL for asset ${asset.name} while moving to trash", + ); + continue; + } + trashMediaUrls.add(mediaUrl); + } else { + await _localFilesManager.restoreFromTrash( + asset.fileName, + asset.type.index, + ); + } + } + + if (trashMediaUrls.isNotEmpty) { + await _localFilesManager.moveToTrash(trashMediaUrls); + } + } + /// 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); + } + try { await _assetRepository.transaction(() async { await _assetRepository.updateAll(assets); diff --git a/mobile/lib/utils/local_files_manager.dart b/mobile/lib/utils/local_files_manager.dart new file mode 100644 index 0000000000..a4cf41a6e6 --- /dev/null +++ b/mobile/lib/utils/local_files_manager.dart @@ -0,0 +1,38 @@ +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +abstract final class LocalFilesManager { + static final Logger _logger = Logger('LocalFilesManager'); + static const MethodChannel _channel = MethodChannel('file_trash'); + + static Future moveToTrash(List mediaUrls) async { + try { + return await _channel + .invokeMethod('moveToTrash', {'mediaUrls': mediaUrls}); + } catch (e, s) { + _logger.warning('Error moving file to trash', e, s); + return false; + } + } + + static Future restoreFromTrash(String fileName, int type) async { + try { + return await _channel.invokeMethod( + 'restoreFromTrash', + {'fileName': fileName, 'type': type}, + ); + } catch (e, s) { + _logger.warning('Error restore file from trash', e, s); + return false; + } + } + + static Future requestManageMediaPermission() async { + try { + return await _channel.invokeMethod('requestManageMediaPermission'); + } catch (e, s) { + _logger.warning('Error requesting manage media permission', e, s); + return false; + } + } +} diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index a2e0e5b95c..d65186a191 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 >= 31; + } + 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) + .requestManageMediaPermission(); + 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 3879e64237..2029ade018 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..87a8c01cf0 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,6 @@ class MockNetworkService extends Mock implements NetworkService {} class MockSearchApi extends Mock implements SearchApi {} +class MockAppSettingService extends Mock implements AppSettingsService {} + class MockBackgroundService extends Mock implements BackgroundService {}