mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-26 19:31:45 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user