feat(android13): make All Files Access optional

- Android 13+ now only requires READ_MEDIA_AUDIO by default
- MANAGE_EXTERNAL_STORAGE is optional and can be enabled in Settings
- Added 'All Files Access' toggle in Download Settings (Android 13+ only)
- Users who encounter write errors can enable full storage access
- Respects privacy-conscious users who prefer limited permissions
This commit is contained in:
zarzet
2026-01-31 14:08:59 +07:00
parent 020ac32ee6
commit 9bbd774175
20 changed files with 526 additions and 42 deletions
+42
View File
@@ -3957,6 +3957,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Failed to fetch some albums'**
String get discographyFailedToFetch;
/// Section header for storage access settings
///
/// In en, this message translates to:
/// **'Storage Access'**
String get sectionStorageAccess;
/// Toggle for MANAGE_EXTERNAL_STORAGE permission
///
/// In en, this message translates to:
/// **'All Files Access'**
String get allFilesAccess;
/// Subtitle when all files access is enabled
///
/// In en, this message translates to:
/// **'Can write to any folder'**
String get allFilesAccessEnabledSubtitle;
/// Subtitle when all files access is disabled
///
/// In en, this message translates to:
/// **'Limited to media folders only'**
String get allFilesAccessDisabledSubtitle;
/// Description explaining when to enable all files access
///
/// In en, this message translates to:
/// **'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.'**
String get allFilesAccessDescription;
/// Message when permission is permanently denied
///
/// In en, this message translates to:
/// **'Permission was denied. Please enable \'All files access\' manually in system settings.'**
String get allFilesAccessDeniedMessage;
/// Snackbar message when user disables all files access
///
/// In en, this message translates to:
/// **'All Files Access disabled. The app will use limited storage access.'**
String get allFilesAccessDisabledMessage;
}
class _AppLocalizationsDelegate
+24
View File
@@ -2199,4 +2199,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2184,4 +2184,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2184,6 +2184,30 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+24
View File
@@ -2184,4 +2184,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2184,4 +2184,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2197,4 +2197,28 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Gagal mengambil beberapa album';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2170,4 +2170,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2184,4 +2184,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2184,4 +2184,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2184,6 +2184,30 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+24
View File
@@ -2230,4 +2230,28 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get discographyFailedToFetch =>
'Не удалось получить некоторые альбомы';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2199,4 +2199,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Bazı albümler alınamadı';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
+24
View File
@@ -2184,6 +2184,30 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
String get sectionStorageAccess => 'Storage Access';
@override
String get allFilesAccess => 'All Files Access';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
@override
String get allFilesAccessDescription =>
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
@override
String get allFilesAccessDeniedMessage =>
'Permission was denied. Please enable \'All files access\' manually in system settings.';
@override
String get allFilesAccessDisabledMessage =>
'All Files Access disabled. The app will use limited storage access.';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+16 -1
View File
@@ -1646,5 +1646,20 @@
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"}
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"},
"sectionStorageAccess": "Storage Access",
"@sectionStorageAccess": {"description": "Section header for storage access settings"},
"allFilesAccess": "All Files Access",
"@allFilesAccess": {"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"},
"allFilesAccessEnabledSubtitle": "Can write to any folder",
"@allFilesAccessEnabledSubtitle": {"description": "Subtitle when all files access is enabled"},
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
"@allFilesAccessDisabledSubtitle": {"description": "Subtitle when all files access is disabled"},
"allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.",
"@allFilesAccessDescription": {"description": "Description explaining when to enable all files access"},
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"}
}
+4
View File
@@ -35,6 +35,7 @@ class AppSettings {
final String lossyFormat;
final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64'
final String lyricsMode;
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
const AppSettings({
this.defaultService = 'tidal',
@@ -68,6 +69,7 @@ class AppSettings {
this.lossyFormat = 'mp3',
this.lossyBitrate = 'mp3_320',
this.lyricsMode = 'embed',
this.useAllFilesAccess = false,
});
AppSettings copyWith({
@@ -103,6 +105,7 @@ class AppSettings {
String? lossyFormat,
String? lossyBitrate,
String? lyricsMode,
bool? useAllFilesAccess,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -136,6 +139,7 @@ class AppSettings {
lossyFormat: lossyFormat ?? this.lossyFormat,
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
lyricsMode: lyricsMode ?? this.lyricsMode,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
);
}
+2
View File
@@ -40,6 +40,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
lossyFormat: json['lossyFormat'] as String? ?? 'mp3',
lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -75,4 +76,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'lossyFormat': instance.lossyFormat,
'lossyBitrate': instance.lossyBitrate,
'lyricsMode': instance.lyricsMode,
'useAllFilesAccess': instance.useAllFilesAccess,
};
+5
View File
@@ -251,6 +251,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
@@ -3,18 +3,92 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerWidget {
class DownloadSettingsPage extends ConsumerStatefulWidget {
const DownloadSettingsPage({super.key});
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DownloadSettingsPage> createState() => _DownloadSettingsPageState();
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
@override
void initState() {
super.initState();
_initDeviceInfo();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final sdkVersion = androidInfo.version.sdkInt;
final hasAccess = await Permission.manageExternalStorage.isGranted;
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
_hasAllFilesAccess = hasAccess;
});
}
}
}
Future<void> _requestAllFilesAccess() async {
final status = await Permission.manageExternalStorage.request();
if (status.isGranted) {
ref.read(settingsProvider.notifier).setUseAllFilesAccess(true);
if (mounted) {
setState(() => _hasAllFilesAccess = true);
}
} else if (status.isPermanentlyDenied) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(context.l10n.allFilesAccessDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
}
}
Future<void> _disableAllFilesAccess() async {
ref.read(settingsProvider.notifier).setUseAllFilesAccess(false);
// Note: We can't revoke the permission programmatically,
// but we can stop using it in the app
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.allFilesAccessDisabledMessage)),
);
}
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
@@ -270,6 +344,59 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// All Files Access section (Android 13+ only)
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionStorageAccess),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.folder_special_outlined,
title: context.l10n.allFilesAccess,
subtitle: _hasAllFilesAccess
? context.l10n.allFilesAccessEnabledSubtitle
: context.l10n.allFilesAccessDisabledSubtitle,
value: _hasAllFilesAccess && settings.useAllFilesAccess,
onChanged: (value) {
if (value) {
_requestAllFilesAccess();
} else {
_disableAllFilesAccess();
}
},
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 16,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.allFilesAccessDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
+14 -37
View File
@@ -67,10 +67,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool storageGranted = false;
if (_androidSdkVersion >= 33) {
final manageStatus = await Permission.manageExternalStorage.status;
// Android 13+: Only require READ_MEDIA_AUDIO by default
// MANAGE_EXTERNAL_STORAGE is optional and can be enabled in settings
final audioStatus = await Permission.audio.status;
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
debugPrint('[Permission] Android 13+ check: READ_MEDIA_AUDIO=$audioStatus');
storageGranted = audioStatus.isGranted;
} else if (_androidSdkVersion >= 30) {
final manageStatus = await Permission.manageExternalStorage.status;
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
@@ -108,44 +109,20 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool allGranted = false;
if (_androidSdkVersion >= 33) {
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupStorageAccessRequired),
content: Text(
'${context.l10n.setupStorageAccessMessage}\n\n'
'${context.l10n.setupAllowAccessToManageFiles}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.setupOpenSettings),
),
],
),
);
if (shouldOpen == true) {
await Permission.manageExternalStorage.request();
await Future.delayed(const Duration(milliseconds: 500));
manageStatus = await Permission.manageExternalStorage.status;
}
}
}
// Android 13+: Only request READ_MEDIA_AUDIO by default
// MANAGE_EXTERNAL_STORAGE is optional (can be enabled in Settings)
var audioStatus = await Permission.audio.status;
if (!audioStatus.isGranted && manageStatus.isGranted) {
if (!audioStatus.isGranted) {
audioStatus = await Permission.audio.request();
}
allGranted = manageStatus.isGranted && audioStatus.isGranted;
allGranted = audioStatus.isGranted;
if (audioStatus.isPermanentlyDenied) {
_showPermissionDeniedDialog('Audio');
setState(() => _isLoading = false);
return;
}
} else if (_androidSdkVersion >= 30) {
var manageStatus = await Permission.manageExternalStorage.status;