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-10 10:12:16 +07:00
parent 53824b7ac2
commit 927fc8abac
22 changed files with 389 additions and 24 deletions
+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),