diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b0dc2bc..854159ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Info: edit location by copying from other item - Info: edit tags with dynamic placeholders for country / place - Widget: option to open collection on tap +- optional MANAGE_MEDIA permission to modify media without asking ### Changed diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5e4b6da99..a17675bce 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,9 +24,14 @@ This change eventually prevents building the app with Flutter v3.3.3. android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" tools:ignore="ScopedStorage" /> + + + + - diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index eda3f1d32..f9ca4d742 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -3,7 +3,10 @@ package deckers.thibault.aves.channel.calls import android.content.Context import android.content.Intent import android.content.res.Resources +import android.net.Uri import android.os.Build +import android.provider.MediaStore +import android.provider.Settings import androidx.core.content.pm.ShortcutManagerCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.model.FieldMap @@ -15,15 +18,21 @@ import java.util.* class DeviceHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { + "canManageMedia" -> safe(call, result, ::canManageMedia) "getCapabilities" -> safe(call, result, ::getCapabilities) "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) "getLocales" -> safe(call, result, ::getLocales) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass) "isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled) + "requestMediaManagePermission" -> safe(call, result, ::requestMediaManagePermission) else -> result.notImplemented() } } + private fun canManageMedia(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + result.success(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaStore.canManageMedia(context) else false) + } + private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { val sdkInt = Build.VERSION.SDK_INT result.success( @@ -32,6 +41,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { "canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context), "canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT), "canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP), + "canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S), "canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N), "isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S), "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O), @@ -90,6 +100,17 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { result.success(enabled) } + private fun requestMediaManagePermission(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + result.error("requestMediaManagePermission-unsupported", "media management permission is not available before Android 12", null) + return + } + + val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}")) + context.startActivity(intent) + result.success(true) + } + companion object { const val CHANNEL = "deckers.thibault/aves/device" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 47e0028f8..9c0bd807b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -760,6 +760,7 @@ "settingsSaveSearchHistory": "Save search history", "settingsEnableBin": "Use recycle bin", "settingsEnableBinSubtitle": "Keep deleted items for 30 days", + "settingsAllowMediaManagement": "Allow media management", "settingsHiddenItemsTile": "Hidden items", "settingsHiddenItemsPageTitle": "Hidden Items", diff --git a/lib/model/device.dart b/lib/model/device.dart index c94b0adf2..d599ab64f 100644 --- a/lib/model/device.dart +++ b/lib/model/device.dart @@ -5,7 +5,7 @@ final Device device = Device._private(); class Device { late final String _userAgent; - late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canSetLockScreenWallpaper; + late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper; late final bool _isDynamicColorAvailable, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; String get userAgent => _userAgent; @@ -18,6 +18,8 @@ class Device { bool get canRenderFlagEmojis => _canRenderFlagEmojis; + bool get canRequestManageMedia => _canRequestManageMedia; + bool get canSetLockScreenWallpaper => _canSetLockScreenWallpaper; bool get isDynamicColorAvailable => _isDynamicColorAvailable; @@ -37,6 +39,7 @@ class Device { _canPinShortcut = capabilities['canPinShortcut'] ?? false; _canPrint = capabilities['canPrint'] ?? false; _canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false; + _canRequestManageMedia = capabilities['canRequestManageMedia'] ?? false; _canSetLockScreenWallpaper = capabilities['canSetLockScreenWallpaper'] ?? false; _isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false; _showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false; diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index 5bafe40f7..b6d0612cb 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -4,6 +4,8 @@ import 'package:aves/services/common/services.dart'; import 'package:flutter/services.dart'; abstract class DeviceService { + Future canManageMedia(); + Future> getCapabilities(); Future getDefaultTimeZone(); @@ -13,11 +15,24 @@ abstract class DeviceService { Future getPerformanceClass(); Future isSystemFilePickerEnabled(); + + Future requestMediaManagePermission(); } class PlatformDeviceService implements DeviceService { static const _platform = MethodChannel('deckers.thibault/aves/device'); + @override + Future canManageMedia() async { + try { + final result = await _platform.invokeMethod('canManageMedia'); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + @override Future> getCapabilities() async { try { @@ -80,4 +95,13 @@ class PlatformDeviceService implements DeviceService { } return false; } + + @override + Future requestMediaManagePermission() async { + try { + await _platform.invokeMethod('requestMediaManagePermission'); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } } diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index 4a07263a0..8d2b58b59 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:aves/app_flavor.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -33,6 +34,7 @@ class PrivacySection extends SettingsSection { return [ SettingsTilePrivacyAllowInstalledAppAccess(), if (canEnableErrorReporting) SettingsTilePrivacyAllowErrorReporting(), + if (device.canRequestManageMedia) SettingsTilePrivacyManageMedia(), SettingsTilePrivacySaveSearchHistory(), SettingsTilePrivacyEnableBin(), SettingsTilePrivacyHiddenItems(), @@ -124,3 +126,65 @@ class SettingsTilePrivacyStorageAccess extends SettingsTile { builder: (context) => const StorageAccessPage(), ); } + +class SettingsTilePrivacyManageMedia extends SettingsTile { + @override + String title(BuildContext context) => context.l10n.settingsAllowMediaManagement; + + @override + Widget build(BuildContext context) => _ManageMediaTile(title: title(context)); +} + +class _ManageMediaTile extends StatefulWidget { + final String title; + + const _ManageMediaTile({ + required this.title, + }); + + @override + State<_ManageMediaTile> createState() => _ManageMediaTileState(); +} + +class _ManageMediaTileState extends State<_ManageMediaTile> with WidgetsBindingObserver { + late Future _loader; + + @override + void initState() { + super.initState(); + _initLoader(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + void _initLoader() => _loader = deviceService.canManageMedia(); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _initLoader(); + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _loader, + builder: (context, snapshot) { + final loading = snapshot.connectionState != ConnectionState.done; + final current = snapshot.data ?? false; + return SwitchListTile( + value: current, + onChanged: loading ? null : (v) => deviceService.requestMediaManagePermission(), + title: Text(widget.title), + ); + }, + ); + } +} diff --git a/untranslated.json b/untranslated.json index 1e893420e..287980953 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,6 +1,7 @@ { "de": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -8,6 +9,7 @@ "el": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -18,6 +20,7 @@ "widgetOpenPageCollection", "widgetOpenPageViewer", "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -507,6 +510,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", "settingsHiddenItemsTabFilters", @@ -606,6 +610,7 @@ "fr": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -962,6 +967,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", "settingsHiddenItemsTabFilters", @@ -1073,6 +1079,7 @@ "albumMimeTypeMixed", "settingsDisabled", "settingsSlideshowAnimatedZoomEffect", + "settingsAllowMediaManagement", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "wallpaperUseScrollEffect", @@ -1083,6 +1090,7 @@ "it": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -1118,6 +1126,7 @@ "settingsConfirmationAfterMoveToBinItems", "settingsViewerGestureSideTapNext", "settingsSlideshowAnimatedZoomEffect", + "settingsAllowMediaManagement", "settingsWidgetOpenPage", "statsTopAlbumsSectionTitle", "viewerInfoLabelDescription", @@ -1129,6 +1138,7 @@ "ko": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -1222,6 +1232,7 @@ "settingsSlideshowShuffle", "settingsSubtitleThemeSample", "settingsAllowInstalledAppAccess", + "settingsAllowMediaManagement", "settingsHiddenFiltersBanner", "settingsHiddenFiltersEmpty", "settingsHiddenItemsTabPaths", @@ -1253,6 +1264,7 @@ "editEntryLocationDialogSetCustom", "aboutLinkPolicy", "policyPageTitle", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -1649,6 +1661,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", "settingsHiddenItemsTabFilters", @@ -1748,6 +1761,7 @@ "pt": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -1755,6 +1769,7 @@ "ru": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace" @@ -1813,6 +1828,7 @@ "settingsSlideshowIntervalTile", "settingsSlideshowVideoPlaybackTile", "settingsSlideshowVideoPlaybackDialogTitle", + "settingsAllowMediaManagement", "settingsScreenSaverPageTitle", "settingsWidgetShowOutline", "settingsWidgetOpenPage", @@ -1827,6 +1843,7 @@ "zh": [ "editEntryLocationDialogSetCustom", + "settingsAllowMediaManagement", "tagEditorSectionPlaceholders", "tagPlaceholderCountry", "tagPlaceholderPlace"