feat(downloads): detect lost SAF grant and offer folder re-selection

A persisted SAF tree grant can die silently (folder deleted or renamed,
SD card removed, app data reset, system revocation). Downloads then fail
with no recovery path beyond digging through Settings.

- Add isSafTreeAccessible bridge method: the grant must still be in the
  system's persisted permission list and the tree writable
- Show a warning banner in Files & Folders when the saved SAF folder is
  no longer accessible, with an inline re-select action
- In the download error dialog, replace Retry with a Re-select folder
  action when the failure is a lost folder grant (Android SAF or iOS
  bookmark); a successful re-pick retries the item automatically
- Share the folder-access error messages as constants so the UI can
  match on them reliably
This commit is contained in:
zarzet
2026-07-09 19:19:01 +07:00
parent 53824b7ac2
commit 927fc8abac
22 changed files with 389 additions and 24 deletions
@@ -2414,6 +2414,27 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(exists)
}
"isSafTreeAccessible" -> {
val uriStr = call.argument<String>("tree_uri") ?: ""
val accessible = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(uriStr)
val persisted = contentResolver.persistedUriPermissions.any {
it.uri == uri && it.isReadPermission && it.isWritePermission
}
if (!persisted) {
false
} else {
val doc = DocumentFile.fromTreeUri(this@MainActivity, uri)
doc != null && doc.exists() && doc.canWrite()
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "SAF tree access check failed: ${e.message}")
false
}
}
result.success(accessible)
}
"safDelete" -> {
val uriStr = call.argument<String>("uri") ?: ""
val deleted = withContext(Dispatchers.IO) {
+18
View File
@@ -5335,6 +5335,24 @@ abstract class AppLocalizations {
/// **'Pick any folder, including SD card'**
String get storageModeSafSubtitle;
/// Title of the warning banner shown in Files settings when the saved SAF folder grant is no longer valid
///
/// In en, this message translates to:
/// **'Download folder access lost'**
String get downloadFolderAccessLostTitle;
/// Subtitle of the warning banner shown when the saved SAF folder grant is no longer valid
///
/// In en, this message translates to:
/// **'Downloads will fail until you re-select the folder'**
String get downloadFolderAccessLostSubtitle;
/// Button that reopens the folder picker to restore download folder access
///
/// In en, this message translates to:
/// **'Re-select folder'**
String get downloadFolderReselect;
/// Description shown in filename format editor
///
/// In en, this message translates to:
+10
View File
@@ -3129,6 +3129,16 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3169,6 +3169,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get storageModeSafSubtitle =>
'Wähle einen beliebigen Ordner, inklusive SD-Karte';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3129,6 +3129,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3129,6 +3129,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3213,6 +3213,16 @@ class AppLocalizationsFr extends AppLocalizations {
String get storageModeSafSubtitle =>
'Choisissez n\'importe quel dossier, y compris la carte SD';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3129,6 +3129,16 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3112,6 +3112,16 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3116,6 +3116,16 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3114,6 +3114,16 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3129,6 +3129,16 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3129,6 +3129,16 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3184,6 +3184,16 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3160,6 +3160,16 @@ class AppLocalizationsTr extends AppLocalizations {
String get storageModeSafSubtitle =>
'SD kart dahil herhangi bir klasörü seçin';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3176,6 +3176,16 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+10
View File
@@ -3129,6 +3129,16 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String get downloadFolderAccessLostTitle => 'Download folder access lost';
@override
String get downloadFolderAccessLostSubtitle =>
'Downloads will fail until you re-select the folder';
@override
String get downloadFolderReselect => 'Re-select folder';
@override
String downloadFilenameDescription(
Object album,
+12
View File
@@ -4110,6 +4110,18 @@
"@storageModeSafSubtitle": {
"description": "Subtitle for SAF storage mode"
},
"downloadFolderAccessLostTitle": "Download folder access lost",
"@downloadFolderAccessLostTitle": {
"description": "Title of the warning banner shown in Files settings when the saved SAF folder grant is no longer valid"
},
"downloadFolderAccessLostSubtitle": "Downloads will fail until you re-select the folder",
"@downloadFolderAccessLostSubtitle": {
"description": "Subtitle of the warning banner shown when the saved SAF folder grant is no longer valid"
},
"downloadFolderReselect": "Re-select folder",
"@downloadFolderReselect": {
"description": "Button that reopens the folder picker to restore download folder access"
},
"downloadFilenameDescription": "Use {artist}, {title}, {album}, {track}, {year}, {date}, {disc} as placeholders.",
"@downloadFilenameDescription": {
"description": "Description shown in filename format editor"
+12 -4
View File
@@ -30,6 +30,16 @@ export 'package:spotiflac_android/services/history_database.dart'
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
/// Set on queued items when the persisted Android SAF grant fails validation.
/// The queue UI matches on this to offer re-selecting the download folder.
const String safPermissionLostErrorMessage =
'SAF permission invalid or revoked. Please reconfigure download location in Settings.';
/// Set on queued items when the iOS download folder bookmark can no longer be
/// opened. The queue UI matches on this to offer re-selecting the folder.
const String downloadFolderAccessLostErrorMessage =
'Download folder access lost. Please re-select your download folder in Settings.';
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
final _trimDotsAndSpacesRegex = RegExp(r'^[. ]+|[. ]+$');
final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
@@ -7114,8 +7124,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error:
'SAF permission invalid or revoked. Please reconfigure download location in Settings.',
error: safPermissionLostErrorMessage,
);
}
}
@@ -7146,8 +7155,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error:
'Download folder access lost. Please re-select your download folder in Settings.',
error: downloadFolderAccessLostErrorMessage,
);
}
}
+73 -4
View File
@@ -3412,6 +3412,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
) async {
final colorScheme = Theme.of(context).colorScheme;
final isRateLimit = item.errorType == DownloadErrorType.rateLimit;
final isFolderAccessLost =
item.errorMessage == safPermissionLostErrorMessage ||
item.errorMessage == downloadFolderAccessLostErrorMessage;
final title = isRateLimit
? context.l10n.queueRateLimitTitle
: context.l10n.updateDownloadFailed;
@@ -3453,10 +3456,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
onPressed: () => Navigator.of(ctx).pop(),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop('retry'),
child: Text(context.l10n.dialogRetry),
),
if (isFolderAccessLost)
FilledButton(
onPressed: () => Navigator.of(ctx).pop('reselect'),
child: Text(context.l10n.downloadFolderReselect),
)
else
FilledButton(
onPressed: () => Navigator.of(ctx).pop('retry'),
child: Text(context.l10n.dialogRetry),
),
],
),
);
@@ -3465,9 +3474,69 @@ class _QueueTabState extends ConsumerState<QueueTab> {
ref.read(downloadQueueProvider.notifier).retryItem(item.id);
} else if (action == 'remove') {
ref.read(downloadQueueProvider.notifier).removeItem(item.id);
} else if (action == 'reselect') {
final reselected = await _reselectDownloadFolder();
if (reselected && mounted) {
ref.read(downloadQueueProvider.notifier).retryItem(item.id);
}
}
}
/// Reopen the platform folder picker to restore download folder access,
/// then persist the new location. Returns true when a folder was saved.
Future<bool> _reselectDownloadFolder() async {
if (Platform.isAndroid) {
final result = await PlatformBridge.pickSafTree();
if (result == null) return false;
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isEmpty) return false;
final notifier = ref.read(settingsProvider.notifier);
notifier.setStorageMode('saf');
notifier.setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty ? displayName : treeUri,
);
return true;
}
if (Platform.isIOS) {
IosPickedDirectory? picked;
try {
picked = await PlatformBridge.pickIosDirectory();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
),
),
);
}
return false;
}
if (picked == null) return false;
final validation = validateIosPath(picked.path);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ?? context.l10n.setupIcloudNotSupported,
),
),
);
}
return false;
}
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(picked.path, iosBookmark: picked.bookmark);
return true;
}
return false;
}
Widget _buildDownloadGridItem(
BuildContext context,
DownloadItem item,
+87 -16
View File
@@ -23,11 +23,40 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
bool _safAccessLost = false;
@override
void initState() {
super.initState();
_initDeviceInfo();
_checkSafAccess();
}
Future<void> _checkSafAccess() async {
if (!Platform.isAndroid) return;
final settings = ref.read(settingsProvider);
if (settings.storageMode != 'saf' || settings.downloadTreeUri.isEmpty) {
if (mounted && _safAccessLost) setState(() => _safAccessLost = false);
return;
}
final accessible = await PlatformBridge.isSafTreeAccessible(
settings.downloadTreeUri,
);
if (mounted) setState(() => _safAccessLost = !accessible);
}
Future<void> _pickSafTreeAndSave() async {
final result = await PlatformBridge.pickSafTree();
if (result == null) return;
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isEmpty) return;
final notifier = ref.read(settingsProvider.notifier);
notifier.setStorageMode('saf');
notifier.setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty ? displayName : treeUri,
);
}
Future<void> _initDeviceInfo() async {
@@ -156,6 +185,62 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
],
),
),
if (Platform.isAndroid &&
_safAccessLost &&
settings.storageMode == 'saf' &&
settings.downloadTreeUri.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadFolderAccessLostTitle,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onErrorContainer,
),
),
const SizedBox(height: 2),
Text(
context.l10n.downloadFolderAccessLostSubtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onErrorContainer
.withValues(alpha: 0.8),
),
),
],
),
),
TextButton(
onPressed: () async {
await _pickSafTreeAndSave();
await _checkSafAccess();
},
child: Text(context.l10n.downloadFolderReselect),
),
],
),
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
@@ -522,22 +607,8 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
trailing: isSafMode ? const Icon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setStorageMode('saf');
ref
.read(settingsProvider.notifier)
.setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty
? displayName
: treeUri,
);
}
}
await _pickSafTreeAndSave();
await _checkSafAccess();
},
),
const SizedBox(height: 8),
+16
View File
@@ -598,6 +598,22 @@ class PlatformBridge {
return result as bool;
}
/// Whether the persisted SAF grant for [treeUri] is still usable: the
/// permission is present in the system's persisted list and the tree
/// document still exists and is writable. Returns true on channel errors
/// so a bridge problem never raises a false "access lost" alarm.
static Future<bool> isSafTreeAccessible(String treeUri) async {
try {
final result = await _channel.invokeMethod('isSafTreeAccessible', {
'tree_uri': treeUri,
});
return result as bool? ?? true;
} catch (e) {
_log.w('Failed to check SAF tree accessibility: $e');
return true;
}
}
static Future<bool> safDelete(String uri) async {
final result = await _channel.invokeMethod('safDelete', {'uri': uri});
return result as bool;