fix(ios): create security-scoped bookmark inside native folder picker session

The download/library folder picker on iOS previously took the bare path
string returned by file_picker and rebuilt a URL from it to create a
security-scoped bookmark. By then the picker's access grant is gone, so
bookmark creation either failed (folder selection silently rejected) or
produced a bookmark that could not be re-opened, after which the first
download run overwrote the user's chosen folder back to app Documents.

- Add a native pickIosDirectory method channel that presents
  UIDocumentPickerViewController itself and creates the bookmark inside
  the delegate callback, while the security-scoped grant is active
- Use it for the download directory (settings + first-run setup) and
  the local library folder pickers
- Stop overwriting the saved download directory when the bookmark fails
  at queue start; fail queued items with a clear message instead
- Skip the launch-time iOS path normalizer when a bookmark exists, so
  it no longer rewrites external folder paths and drops their bookmark

Fixes #454
This commit is contained in:
zarzet
2026-07-10 10:12:16 +07:00
parent fd7424dd81
commit 53824b7ac2
7 changed files with 200 additions and 117 deletions
+15 -4
View File
@@ -7137,12 +7137,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: resolvedPath);
}
} else {
_log.e('Failed to access iOS download folder bookmark');
_log.w(
'Failed to access iOS download folder bookmark, falling back to app Documents folder',
'The saved download folder may have been moved, deleted, or its access grant lost',
);
final musicDir = await _ensureDefaultDocumentsOutputDir();
state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
for (final item in state.items) {
if (item.status == DownloadStatus.queued) {
updateItemStatus(
item.id,
DownloadStatus.failed,
error:
'Download folder access lost. Please re-select your download folder in Settings.',
);
}
}
state = state.copyWith(isProcessing: false);
await PlatformBridge.endBackgroundDownloadTask();
return;
}
}
+7
View File
@@ -252,6 +252,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return;
// A security-scoped bookmark is the source of truth for folders picked
// from Files: its stored path may legitimately point outside the app
// container, and the download flow re-resolves the real path from the
// bookmark. "Fixing" the path here would also drop the bookmark and
// silently revert the user's chosen folder.
if (state.downloadDirectoryBookmark.isNotEmpty) return;
final currentDir = state.downloadDirectory.trim();
if (currentDir.isEmpty) return;
+25 -50
View File
@@ -1,7 +1,6 @@
import 'dart:io';
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';
@@ -599,12 +598,10 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
onTap: () async {
Navigator.pop(ctx);
if (Platform.isIOS) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
String? result;
await Future<void>.delayed(const Duration(milliseconds: 250));
IosPickedDirectory? picked;
try {
result = await FilePicker.getDirectoryPath();
picked = await PlatformBridge.pickIosDirectory();
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
@@ -619,53 +616,31 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
}
return;
}
if (result != null) {
if (Platform.isIOS) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
if (picked == null) return;
final bookmark =
await PlatformBridge.createIosBookmarkFromPath(result);
if (bookmark == null || bookmark.isEmpty) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(
ctx.l10n.snackbarFolderPickerFailed(
ctx.l10n.errorCouldNotKeepFolderAccess,
),
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(result, iosBookmark: bookmark);
return;
final validation = validateIosPath(picked.path);
if (!validation.isValid) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(result);
return;
}
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(
picked.path,
iosBookmark: picked.bookmark,
);
},
),
Padding(
+24 -14
View File
@@ -121,22 +121,32 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final granted = await _requestStoragePermission();
if (!granted) return;
}
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;
}
if (picked != null) {
ref
.read(settingsProvider.notifier)
.setLocalLibraryPathAndBookmark(picked.path, picked.bookmark);
}
return;
}
final result = await FilePicker.getDirectoryPath();
if (result != null) {
if (Platform.isIOS) {
final bookmark = await PlatformBridge.createIosBookmarkFromPath(
result,
);
if (bookmark != null && bookmark.isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setLocalLibraryPathAndBookmark(result, bookmark);
} else {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
} else {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
}
}
+22 -48
View File
@@ -2,7 +2,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:go_router/go_router.dart';
import 'package:device_info_plus/device_info_plus.dart';
@@ -354,13 +353,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
title: Text(context.l10n.setupChooseFromFiles),
onTap: () async {
Navigator.pop(ctx);
if (Platform.isIOS) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
await Future<void>.delayed(const Duration(milliseconds: 250));
String? result;
IosPickedDirectory? picked;
try {
result = await FilePicker.getDirectoryPath();
picked = await PlatformBridge.pickIosDirectory();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -376,7 +373,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
return;
}
if (result == null) {
if (picked == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -387,52 +384,29 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
return;
}
// iOS: Validate the selected path is writable
if (Platform.isIOS) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ??
context.l10n.errorInvalidFolderSelected,
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
// Validate the selected path is writable
final validation = validateIosPath(picked.path);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
validation.errorReason ??
context.l10n.errorInvalidFolderSelected,
),
);
}
return;
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
final bookmark =
await PlatformBridge.createIosBookmarkFromPath(result);
if (bookmark == null || bookmark.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(
context.l10n.errorCouldNotKeepFolderAccess,
),
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
setState(() {
_selectedDirectory = result;
_selectedDirectoryBookmark = bookmark;
});
return;
}
setState(() => _selectedDirectory = result);
final pickedDir = picked;
setState(() {
_selectedDirectory = pickedDir.path;
_selectedDirectoryBookmark = pickedDir.bookmark;
});
},
),
const SizedBox(height: 16),
+23
View File
@@ -22,6 +22,15 @@ class ExtensionSessionGrantEvent {
});
}
/// Folder picked via the native iOS document picker: the filesystem path plus
/// the security-scoped bookmark that re-grants access across app launches.
class IosPickedDirectory {
final String path;
final String bookmark;
const IosPickedDirectory({required this.path, required this.bookmark});
}
class _BridgeCacheEntry {
final Map<String, dynamic> value;
final DateTime expiresAt;
@@ -2041,6 +2050,20 @@ class PlatformBridge {
return const <String, dynamic>{};
}
/// Present the native iOS folder picker. The security-scoped bookmark is
/// created inside the picker callback while its access grant is still
/// active — creating it later from a bare path loses the grant.
/// Returns null when the user cancels; throws on picker/bookmark failure.
static Future<IosPickedDirectory?> pickIosDirectory() async {
final result = await _channel.invokeMethod('pickIosDirectory');
final map = _decodeNullableMapResult(result, 'pickIosDirectory');
if (map == null) return null;
final path = map['path'] as String? ?? '';
final bookmark = map['bookmark'] as String? ?? '';
if (path.isEmpty || bookmark.isEmpty) return null;
return IosPickedDirectory(path: path, bookmark: bookmark);
}
/// Create a security-scoped bookmark from a filesystem path picked by
/// FilePicker on iOS. Must be called while the picker session is still active.
/// Returns base64-encoded bookmark data, or null on failure.