mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-12 02:37:18 +02:00
fix(ios): use security-scoped bookmarks for download directory persistence
- Switch iOS bookmark creation from .minimalBookmark to .withSecurityScope - Add .withSecurityScope option when resolving bookmarks - Add downloadDirectoryBookmark field to AppSettings for persisting iOS bookmarks - Resolve bookmark and startAccessingIosBookmark before queue processing - Guarantee stopAccessingIosBookmark cleanup via try/finally - Create bookmark on folder pick in both setup screen and download settings - Clear bookmark when switching to SAF mode or iOS path normalization - Fix stale bottom sheet context usage (ctx -> context) in download settings
This commit is contained in:
1 parent
64dbf4441c
commit
45fa33e1ec
7 files changed
+161
-41
No files matched your search
@@ -1021,7 +1021,7 @@ import Gobackend // Import Go framework
|
|||||||
let url = URL(fileURLWithPath: path)
|
let url = URL(fileURLWithPath: path)
|
||||||
do {
|
do {
|
||||||
let bookmarkData = try url.bookmarkData(
|
let bookmarkData = try url.bookmarkData(
|
||||||
options: .minimalBookmark,
|
options: .withSecurityScope,
|
||||||
includingResourceValuesForKeys: nil,
|
includingResourceValuesForKeys: nil,
|
||||||
relativeTo: nil
|
relativeTo: nil
|
||||||
)
|
)
|
||||||
@@ -1051,7 +1051,7 @@ import Gobackend // Import Go framework
|
|||||||
do {
|
do {
|
||||||
url = try URL(
|
url = try URL(
|
||||||
resolvingBookmarkData: bookmarkData,
|
resolvingBookmarkData: bookmarkData,
|
||||||
options: [],
|
options: .withSecurityScope,
|
||||||
relativeTo: nil,
|
relativeTo: nil,
|
||||||
bookmarkDataIsStale: &isStale
|
bookmarkDataIsStale: &isStale
|
||||||
)
|
)
|
||||||
@@ -1086,7 +1086,7 @@ import Gobackend // Import Go framework
|
|||||||
do {
|
do {
|
||||||
url = try URL(
|
url = try URL(
|
||||||
resolvingBookmarkData: bookmarkData,
|
resolvingBookmarkData: bookmarkData,
|
||||||
options: [],
|
options: .withSecurityScope,
|
||||||
relativeTo: nil,
|
relativeTo: nil,
|
||||||
bookmarkDataIsStale: &isStale
|
bookmarkDataIsStale: &isStale
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class AppSettings {
|
|||||||
final String audioQuality;
|
final String audioQuality;
|
||||||
final String filenameFormat;
|
final String filenameFormat;
|
||||||
final String downloadDirectory;
|
final String downloadDirectory;
|
||||||
|
final String downloadDirectoryBookmark;
|
||||||
final String storageMode; // 'app' or 'saf'
|
final String storageMode; // 'app' or 'saf'
|
||||||
final String downloadTreeUri; // SAF persistable tree URI
|
final String downloadTreeUri; // SAF persistable tree URI
|
||||||
final bool autoFallback;
|
final bool autoFallback;
|
||||||
@@ -89,6 +90,7 @@ class AppSettings {
|
|||||||
this.audioQuality = 'LOSSLESS',
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.filenameFormat = '{title} - {artist}',
|
this.filenameFormat = '{title} - {artist}',
|
||||||
this.downloadDirectory = '',
|
this.downloadDirectory = '',
|
||||||
|
this.downloadDirectoryBookmark = '',
|
||||||
this.storageMode = 'app',
|
this.storageMode = 'app',
|
||||||
this.downloadTreeUri = '',
|
this.downloadTreeUri = '',
|
||||||
this.autoFallback = true,
|
this.autoFallback = true,
|
||||||
@@ -153,6 +155,7 @@ class AppSettings {
|
|||||||
String? audioQuality,
|
String? audioQuality,
|
||||||
String? filenameFormat,
|
String? filenameFormat,
|
||||||
String? downloadDirectory,
|
String? downloadDirectory,
|
||||||
|
String? downloadDirectoryBookmark,
|
||||||
String? storageMode,
|
String? storageMode,
|
||||||
String? downloadTreeUri,
|
String? downloadTreeUri,
|
||||||
bool? autoFallback,
|
bool? autoFallback,
|
||||||
@@ -213,6 +216,8 @@ class AppSettings {
|
|||||||
audioQuality: audioQuality ?? this.audioQuality,
|
audioQuality: audioQuality ?? this.audioQuality,
|
||||||
filenameFormat: filenameFormat ?? this.filenameFormat,
|
filenameFormat: filenameFormat ?? this.filenameFormat,
|
||||||
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
|
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
|
||||||
|
downloadDirectoryBookmark:
|
||||||
|
downloadDirectoryBookmark ?? this.downloadDirectoryBookmark,
|
||||||
storageMode: storageMode ?? this.storageMode,
|
storageMode: storageMode ?? this.storageMode,
|
||||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||||
autoFallback: autoFallback ?? this.autoFallback,
|
autoFallback: autoFallback ?? this.autoFallback,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
||||||
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
||||||
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
||||||
|
downloadDirectoryBookmark: json['downloadDirectoryBookmark'] as String? ?? '',
|
||||||
storageMode: json['storageMode'] as String? ?? 'app',
|
storageMode: json['storageMode'] as String? ?? 'app',
|
||||||
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
|
||||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||||
@@ -86,6 +87,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'audioQuality': instance.audioQuality,
|
'audioQuality': instance.audioQuality,
|
||||||
'filenameFormat': instance.filenameFormat,
|
'filenameFormat': instance.filenameFormat,
|
||||||
'downloadDirectory': instance.downloadDirectory,
|
'downloadDirectory': instance.downloadDirectory,
|
||||||
|
'downloadDirectoryBookmark': instance.downloadDirectoryBookmark,
|
||||||
'storageMode': instance.storageMode,
|
'storageMode': instance.storageMode,
|
||||||
'downloadTreeUri': instance.downloadTreeUri,
|
'downloadTreeUri': instance.downloadTreeUri,
|
||||||
'autoFallback': instance.autoFallback,
|
'autoFallback': instance.autoFallback,
|
||||||
|
|||||||
@@ -4280,6 +4280,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
final isSafMode = _isSafMode(settings);
|
final isSafMode = _isSafMode(settings);
|
||||||
|
var iosDownloadBookmarkActive = false;
|
||||||
if (settings.downloadNetworkMode == 'wifi_only') {
|
if (settings.downloadNetworkMode == 'wifi_only') {
|
||||||
final connectivityResult = await Connectivity().checkConnectivity();
|
final connectivityResult = await Connectivity().checkConnectivity();
|
||||||
final hasWifi = connectivityResult.contains(ConnectivityResult.wifi);
|
final hasWifi = connectivityResult.contains(ConnectivityResult.wifi);
|
||||||
@@ -4391,8 +4392,38 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isSafMode &&
|
||||||
|
Platform.isIOS &&
|
||||||
|
settings.downloadDirectoryBookmark.isNotEmpty) {
|
||||||
|
final resolvedPath = await PlatformBridge.startAccessingIosBookmark(
|
||||||
|
settings.downloadDirectoryBookmark,
|
||||||
|
);
|
||||||
|
if (resolvedPath != null && resolvedPath.isNotEmpty) {
|
||||||
|
iosDownloadBookmarkActive = true;
|
||||||
|
if (resolvedPath != state.outputDir) {
|
||||||
|
_log.i('Resolved iOS download bookmark path: $resolvedPath');
|
||||||
|
state = state.copyWith(outputDir: resolvedPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.w(
|
||||||
|
'Failed to access iOS download folder bookmark, falling back to app Documents folder',
|
||||||
|
);
|
||||||
|
final musicDir = await _ensureDefaultDocumentsOutputDir();
|
||||||
|
state = state.copyWith(outputDir: musicDir.path);
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||||
await _processQueueParallel();
|
try {
|
||||||
|
await _processQueueParallel();
|
||||||
|
} finally {
|
||||||
|
if (iosDownloadBookmarkActive) {
|
||||||
|
await PlatformBridge.stopAccessingIosBookmark();
|
||||||
|
iosDownloadBookmarkActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
final stoppedWhilePaused = state.isPaused;
|
final stoppedWhilePaused = state.isPaused;
|
||||||
final keepConnectivityMonitoring =
|
final keepConnectivityMonitoring =
|
||||||
stoppedWhilePaused && _networkPausedByWifiOnly;
|
stoppedWhilePaused && _networkPausedByWifiOnly;
|
||||||
|
|||||||
@@ -192,7 +192,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
if (normalizedDir == currentDir) return;
|
if (normalizedDir == currentDir) return;
|
||||||
|
|
||||||
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
|
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
|
||||||
state = state.copyWith(downloadDirectory: normalizedDir);
|
state = state.copyWith(
|
||||||
|
downloadDirectory: normalizedDir,
|
||||||
|
downloadDirectoryBookmark: '',
|
||||||
|
);
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +263,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDownloadDirectory(String directory) {
|
void setDownloadDirectory(String directory, {String? iosBookmark}) {
|
||||||
state = state.copyWith(downloadDirectory: directory);
|
state = state.copyWith(
|
||||||
|
downloadDirectory: directory,
|
||||||
|
downloadDirectoryBookmark: iosBookmark ?? '',
|
||||||
|
);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +283,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
downloadTreeUri: uri,
|
downloadTreeUri: uri,
|
||||||
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
|
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
|
||||||
downloadDirectory: nextDisplay,
|
downloadDirectory: nextDisplay,
|
||||||
|
downloadDirectoryBookmark: uri.isNotEmpty
|
||||||
|
? ''
|
||||||
|
: state.downloadDirectoryBookmark,
|
||||||
);
|
);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1420,13 +1420,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
try {
|
try {
|
||||||
result = await FilePicker.platform.getDirectoryPath();
|
result = await FilePicker.platform.getDirectoryPath();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (ctx.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
|
context.l10n.snackbarFolderPickerFailed(e.toString()),
|
||||||
),
|
),
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1439,24 +1439,55 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
final validation = validateIosPath(result);
|
final validation = validateIosPath(result);
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
if (ctx.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
validation.errorReason ??
|
validation.errorReason ??
|
||||||
context.l10n.setupIcloudNotSupported,
|
context.l10n.setupIcloudNotSupported,
|
||||||
),
|
),
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
duration: const Duration(seconds: 4),
|
duration: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final bookmark =
|
||||||
|
await PlatformBridge.createIosBookmarkFromPath(result);
|
||||||
|
if (bookmark == null || bookmark.isEmpty) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarFolderPickerFailed(
|
||||||
|
'Could not keep access to the selected folder',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadDirectory(result, iosBookmark: bookmark);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setDownloadDirectory(result);
|
.setDownloadDirectory(result);
|
||||||
|
} else if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.setupNoFolderSelected)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
bool _storagePermissionGranted = false;
|
bool _storagePermissionGranted = false;
|
||||||
bool _notificationPermissionGranted = false;
|
bool _notificationPermissionGranted = false;
|
||||||
String? _selectedDirectory;
|
String? _selectedDirectory;
|
||||||
|
String? _selectedDirectoryBookmark;
|
||||||
String? _selectedTreeUri;
|
String? _selectedTreeUri;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
@@ -338,7 +339,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
title: Text(context.l10n.setupAppDocumentsFolder),
|
title: Text(context.l10n.setupAppDocumentsFolder),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final dir = await _getDefaultDirectory();
|
final dir = await _getDefaultDirectory();
|
||||||
setState(() => _selectedDirectory = dir);
|
setState(() {
|
||||||
|
_selectedDirectory = dir;
|
||||||
|
_selectedDirectoryBookmark = null;
|
||||||
|
});
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -369,30 +373,62 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result != null) {
|
if (result == null) {
|
||||||
// iOS: Validate the selected path is writable
|
if (mounted) {
|
||||||
if (Platform.isIOS) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
final validation = validateIosPath(result);
|
SnackBar(
|
||||||
if (!validation.isValid) {
|
content: Text(context.l10n.setupNoFolderSelected),
|
||||||
if (mounted) {
|
),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
);
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
validation.errorReason ??
|
|
||||||
'Invalid folder selected',
|
|
||||||
),
|
|
||||||
backgroundColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.error,
|
|
||||||
duration: const Duration(seconds: 4),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setState(() => _selectedDirectory = result);
|
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 ?? 'Invalid folder selected',
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bookmark =
|
||||||
|
await PlatformBridge.createIosBookmarkFromPath(result);
|
||||||
|
if (bookmark == null || bookmark.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarFolderPickerFailed(
|
||||||
|
'Could not keep access to the selected folder',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedDirectory = result;
|
||||||
|
_selectedDirectoryBookmark = bookmark;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _selectedDirectory = result);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -426,14 +462,20 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (!Platform.isAndroid ||
|
if (!Platform.isAndroid ||
|
||||||
_selectedTreeUri == null ||
|
_selectedTreeUri == null ||
|
||||||
_selectedTreeUri!.isEmpty) {
|
_selectedTreeUri!.isEmpty) {
|
||||||
final dir = Directory(_selectedDirectory!);
|
final iosBookmark = Platform.isIOS ? _selectedDirectoryBookmark : null;
|
||||||
if (!await dir.exists()) {
|
if (iosBookmark == null || iosBookmark.isEmpty) {
|
||||||
await dir.create(recursive: true);
|
final dir = Directory(_selectedDirectory!);
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ref.read(settingsProvider.notifier).setStorageMode('app');
|
ref.read(settingsProvider.notifier).setStorageMode('app');
|
||||||
ref
|
ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setDownloadDirectory(_selectedDirectory!);
|
.setDownloadDirectory(
|
||||||
|
_selectedDirectory!,
|
||||||
|
iosBookmark: iosBookmark,
|
||||||
|
);
|
||||||
ref.read(settingsProvider.notifier).setDownloadTreeUri('');
|
ref.read(settingsProvider.notifier).setDownloadTreeUri('');
|
||||||
} else {
|
} else {
|
||||||
ref.read(settingsProvider.notifier).setStorageMode('saf');
|
ref.read(settingsProvider.notifier).setStorageMode('saf');
|
||||||
|
|||||||
Reference in new issue
Block a user