mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-25 21:40:57 +02:00
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:
@@ -1,5 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import Gobackend
|
||||
|
||||
@main
|
||||
@@ -23,6 +24,9 @@ import Gobackend
|
||||
/// Currently accessed security-scoped URL for library folder
|
||||
private var activeSecurityScopedURL: URL?
|
||||
|
||||
/// Pending Flutter result for the native folder picker
|
||||
private var pendingDirectoryPickerResult: FlutterResult?
|
||||
|
||||
/// Whether a download queue is active; while true a background task is
|
||||
/// started on each background entry to extend execution time. Main-thread only.
|
||||
private var downloadsActive = false
|
||||
@@ -329,6 +333,9 @@ import Gobackend
|
||||
endBackgroundDownloadTask()
|
||||
result(nil)
|
||||
return
|
||||
case "pickIosDirectory":
|
||||
pickIosDirectory(result: result)
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -1173,8 +1180,40 @@ import Gobackend
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Native Folder Picker
|
||||
|
||||
/// Present a native folder picker and return `{path, bookmark}` where the
|
||||
/// security-scoped bookmark is created inside the picker callback, while
|
||||
/// the picker's access grant is still active. Returns nil on cancel.
|
||||
private func pickIosDirectory(result: @escaping FlutterResult) {
|
||||
if pendingDirectoryPickerResult != nil {
|
||||
result(FlutterError(
|
||||
code: "PICKER_ACTIVE",
|
||||
message: "A folder picker is already active",
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
guard var topController = window?.rootViewController else {
|
||||
result(FlutterError(
|
||||
code: "NO_VIEW_CONTROLLER",
|
||||
message: "No view controller available to present the folder picker",
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
while let presented = topController.presentedViewController {
|
||||
topController = presented
|
||||
}
|
||||
pendingDirectoryPickerResult = result
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder])
|
||||
picker.delegate = self
|
||||
picker.allowsMultipleSelection = false
|
||||
topController.present(picker, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - iOS Security-Scoped Bookmark Helpers
|
||||
|
||||
|
||||
/// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker).
|
||||
/// The path must currently be accessible (within the same picker session).
|
||||
/// Returns base64-encoded bookmark data.
|
||||
@@ -1295,6 +1334,50 @@ import Gobackend
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate: UIDocumentPickerDelegate {
|
||||
func documentPicker(
|
||||
_ controller: UIDocumentPickerViewController,
|
||||
didPickDocumentsAt urls: [URL]
|
||||
) {
|
||||
guard let result = pendingDirectoryPickerResult else { return }
|
||||
pendingDirectoryPickerResult = nil
|
||||
guard let url = urls.first else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
// The bookmark must be created here, from the picker's own URL, while
|
||||
// its security-scoped grant is active. A URL rebuilt from the path
|
||||
// string later has no grant, so bookmark creation either fails or
|
||||
// yields a bookmark that cannot re-open the folder.
|
||||
let didStartAccess = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didStartAccess { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
do {
|
||||
let bookmarkData = try url.bookmarkData(
|
||||
options: [],
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
result([
|
||||
"path": url.path,
|
||||
"bookmark": bookmarkData.base64EncodedString(),
|
||||
])
|
||||
} catch {
|
||||
result(FlutterError(
|
||||
code: "BOOKMARK_FAILED",
|
||||
message: "Failed to create bookmark for \(url.path): \(error.localizedDescription)",
|
||||
details: nil
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
pendingDirectoryPickerResult?(nil)
|
||||
pendingDirectoryPickerResult = nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
|
||||
typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError?
|
||||
typealias CancelHandler = (_ arguments: Any?) -> FlutterError?
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user