From 927fc8abac82cae226d08faaae76255ea234f209 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 9 Jul 2026 19:19:01 +0700 Subject: [PATCH] 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 --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 21 ++++ lib/l10n/app_localizations.dart | 18 +++ lib/l10n/app_localizations_ar.dart | 10 ++ lib/l10n/app_localizations_de.dart | 10 ++ lib/l10n/app_localizations_en.dart | 10 ++ lib/l10n/app_localizations_es.dart | 10 ++ lib/l10n/app_localizations_fr.dart | 10 ++ lib/l10n/app_localizations_hi.dart | 10 ++ lib/l10n/app_localizations_id.dart | 10 ++ lib/l10n/app_localizations_ja.dart | 10 ++ lib/l10n/app_localizations_ko.dart | 10 ++ lib/l10n/app_localizations_nl.dart | 10 ++ lib/l10n/app_localizations_pt.dart | 10 ++ lib/l10n/app_localizations_ru.dart | 10 ++ lib/l10n/app_localizations_tr.dart | 10 ++ lib/l10n/app_localizations_uk.dart | 10 ++ lib/l10n/app_localizations_zh.dart | 10 ++ lib/l10n/arb/app_en.arb | 12 ++ lib/providers/download_queue_provider.dart | 16 ++- lib/screens/queue_tab.dart | 77 ++++++++++++- lib/screens/settings/files_settings_page.dart | 103 +++++++++++++++--- lib/services/platform_bridge.dart | 16 +++ 22 files changed, 389 insertions(+), 24 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index a1ebf92d..f6453496 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2414,6 +2414,27 @@ class MainActivity: FlutterFragmentActivity() { } result.success(exists) } + "isSafTreeAccessible" -> { + val uriStr = call.argument("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("uri") ?: "" val deleted = withContext(Dispatchers.IO) { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 029e223c..2ca9f616 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index c05d5ac9..89f15f8e 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -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, diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index cdf7c8f5..161f3d96 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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, diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3850d199..d66cd81d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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, diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 524e6a4f..17e7acdd 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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, diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f116500d..be383e4d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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, diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 4eb008df..2857a9ae 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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, diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index dc2ba084..691f6515 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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, diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e0cf6673..f0aa6aaa 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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, diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 30c47bf3..3596d031 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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, diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index a3113a48..1dea8c98 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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, diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 59561cff..d2f6ae68 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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, diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3e7cf9bb..c92f3a63 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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, diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index e1b610ca..bfa3ca07 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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, diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index babb5f67..51736a25 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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, diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d9fb7393..16c45ebb 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4c73c4aa..d928cc70 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 290362e5..ec96f1ee 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -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 { 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 { updateItemStatus( item.id, DownloadStatus.failed, - error: - 'Download folder access lost. Please re-select your download folder in Settings.', + error: downloadFolderAccessLostErrorMessage, ); } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 57d84183..a6aa4113 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -3412,6 +3412,9 @@ class _QueueTabState extends ConsumerState { ) 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 { 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 { 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 _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, diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 4f355a5b..9e5a7d58 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -23,11 +23,40 @@ class _FilesSettingsPageState extends ConsumerState { int _androidSdkVersion = 0; bool _hasAllFilesAccess = false; bool _artistFolderFiltersExpanded = false; + bool _safAccessLost = false; @override void initState() { super.initState(); _initDeviceInfo(); + _checkSafAccess(); + } + + Future _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 _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 _initDeviceInfo() async { @@ -156,6 +185,62 @@ class _FilesSettingsPageState extends ConsumerState { ], ), ), + 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 { 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), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 59eb947c..c70037c3 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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 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 safDelete(String uri) async { final result = await _channel.invokeMethod('safDelete', {'uri': uri}); return result as bool;