fix(ios): local library scan fails on iOS due to missing security-scoped bookmark access

This commit is contained in:
zarzet
2026-03-08 14:57:13 +07:00
parent 2c897992c5
commit c35857bb61
10 changed files with 602 additions and 273 deletions
+129
View File
@@ -15,6 +15,9 @@ import Gobackend // Import Go framework
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
/// Currently accessed security-scoped URL for library folder
private var activeSecurityScopedURL: URL?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -915,6 +918,26 @@ import Gobackend // Import Go framework
let response = GobackendReadAudioMetadataJSON(filePath, &error)
if let error = error { throw error }
return response
// iOS Security-Scoped Bookmark for Local Library
case "resolveIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
return try resolveIosBookmark(bookmarkBase64)
case "startAccessingIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
return try startAccessingIosBookmark(bookmarkBase64)
case "stopAccessingIosBookmark":
stopAccessingIosBookmark()
return nil
case "createIosBookmarkFromPath":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
return try createIosBookmarkFromPath(path)
// Lyrics Provider Settings
case "setLyricsProviders":
@@ -954,6 +977,112 @@ import Gobackend // Import Go framework
)
}
}
// 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.
private func createIosBookmarkFromPath(_ path: String) throws -> String {
let url = URL(fileURLWithPath: path)
do {
let bookmarkData = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
return bookmarkData.base64EncodedString()
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"]
)
}
}
/// Resolve a base64-encoded security-scoped bookmark and return the resolved path.
/// Does NOT start accessing the resource.
private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String {
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
url = try URL(
resolvingBookmarkData: bookmarkData,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
return url.path
}
/// Resolve a base64-encoded bookmark, start accessing the security-scoped resource,
/// and return the resolved filesystem path. The resource stays accessed until
/// `stopAccessingIosBookmark()` is called.
private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> String {
// Stop any previously accessed resource first
stopAccessingIosBookmark()
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
url = try URL(
resolvingBookmarkData: bookmarkData,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
guard url.startAccessingSecurityScopedResource() else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"]
)
}
activeSecurityScopedURL = url
return url.path
}
/// Stop accessing the currently active security-scoped resource, if any.
private func stopAccessingIosBookmark() {
if let url = activeSecurityScopedURL {
url.stopAccessingSecurityScopedResource()
activeSecurityScopedURL = nil
}
}
}
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
+5
View File
@@ -57,6 +57,8 @@ class AppSettings {
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
@@ -122,6 +124,7 @@ class AppSettings {
this.songLinkRegion = 'US',
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
this.hasCompletedTutorial = false,
this.lyricsProviders = const [
@@ -185,6 +188,7 @@ class AppSettings {
String? songLinkRegion,
bool? localLibraryEnabled,
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
bool? hasCompletedTutorial,
List<String>? lyricsProviders,
@@ -249,6 +253,7 @@ class AppSettings {
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
+2
View File
@@ -55,6 +55,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
@@ -128,6 +129,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'songLinkRegion': instance.songLinkRegion,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'hasCompletedTutorial': instance.hasCompletedTutorial,
'lyricsProviders': instance.lyricsProviders,
+48 -10
View File
@@ -272,6 +272,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
String? iosBookmark,
}) async {
if (state.isScanning) {
_log.w('Scan already in progress');
@@ -316,8 +317,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
// On iOS, start accessing the security-scoped bookmark so the Go backend
// can read files outside the app sandbox.
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
resolvedPath =
await PlatformBridge.startAccessingIosBookmark(iosBookmark);
if (resolvedPath != null) {
didStartSecurityAccess = true;
_log.i('Started iOS security-scoped access: $resolvedPath');
} else {
_log.w(
'Failed to start iOS security-scoped access, '
'falling back to original path',
);
}
}
final effectiveFolderPath = resolvedPath ?? folderPath;
try {
final isSaf = folderPath.startsWith('content://');
final isSaf = effectiveFolderPath.startsWith('content://');
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
@@ -344,8 +364,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(folderPath)
: await PlatformBridge.scanLibraryFolder(folderPath);
? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
@@ -424,12 +444,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final Map<String, dynamic> result;
if (isSaf) {
result = await PlatformBridge.scanSafTreeIncremental(
folderPath,
effectiveFolderPath,
existingFiles,
);
} else {
result = await PlatformBridge.scanLibraryFolderIncremental(
folderPath,
effectiveFolderPath,
existingFiles,
);
}
@@ -553,6 +573,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally {
if (didStartSecurityAccess) {
await PlatformBridge.stopAccessingIosBookmark();
_log.i('Stopped iOS security-scoped access');
}
_stopProgressPolling();
}
}
@@ -807,12 +831,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return decoded;
}
Future<int> cleanupMissingFiles() async {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
Future<int> cleanupMissingFiles({String? iosBookmark}) async {
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
final resolved =
await PlatformBridge.startAccessingIosBookmark(iosBookmark);
if (resolved != null) {
didStartSecurityAccess = true;
}
}
try {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
}
return removed;
} finally {
if (didStartSecurityAccess) {
await PlatformBridge.stopAccessingIosBookmark();
}
}
return removed;
}
Future<void> clearLibrary() async {
+13
View File
@@ -532,6 +532,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setLocalLibraryBookmark(String bookmark) {
state = state.copyWith(localLibraryBookmark: bookmark);
_saveSettings();
}
void setLocalLibraryPathAndBookmark(String path, String bookmark) {
state = state.copyWith(
localLibraryPath: path,
localLibraryBookmark: bookmark,
);
_saveSettings();
}
void setLocalLibraryShowDuplicates(bool show) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
+12 -2
View File
@@ -487,6 +487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -726,6 +727,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
tooltip: 'Play track',
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
@@ -950,13 +952,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim();
final settings = ref.read(settingsProvider);
final localLibraryPath = settings.localLibraryPath.trim();
final iosBookmark = settings.localLibraryBookmark;
try {
if (localLibraryPath.isNotEmpty &&
!ref.read(localLibraryProvider).isScanning) {
await ref
.read(localLibraryProvider.notifier)
.startScan(localLibraryPath);
.startScan(
localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
@@ -1447,6 +1454,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
+309 -254
View File
@@ -1064,6 +1064,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
children: [
IconButton.filledTonal(
onPressed: _exitPlaylistSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -3068,37 +3071,41 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
return Semantics(
button: true,
label: 'Open $title, $count ${count == 1 ? 'item' : 'items'}',
child: GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
),
),
),
const SizedBox(height: 6),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
Text(
'$count ${count == 1 ? 'item' : 'items'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 6),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
),
],
Text(
'$count ${count == 1 ? 'item' : 'items'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -3913,112 +3920,117 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
album.sampleFilePath,
);
return GestureDetector(
onTap: () => _navigateToDownloadedAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
return Semantics(
button: true,
label:
'Open album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks',
child: GestureDetector(
onTap: () => _navigateToDownloadedAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
)
: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 300,
memCacheHeight: 300,
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
)
: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 300,
memCacheHeight: 300,
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
),
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 12,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
),
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 12,
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
],
],
),
),
),
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
ClickableArtistName(
artistName: album.artistName,
coverUrl: album.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
],
ClickableArtistName(
artistName: album.artistName,
coverUrl: album.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -4029,102 +4041,107 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_GroupedLocalAlbum album,
ColorScheme colorScheme,
) {
return GestureDetector(
onTap: () => _navigateToLocalAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverPath != null
? Image.file(
File(album.coverPath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
return Semantics(
button: true,
label:
'Open local album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks',
child: GestureDetector(
onTap: () => _navigateToLocalAlbum(album),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: album.coverPath != null
? Image.file(
File(album.coverPath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
cacheWidth: 300,
cacheHeight: 300,
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 48,
),
),
),
),
// "Local" badge instead of track count
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder,
size: 12,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
),
// "Local" badge instead of track count
Positioned(
right: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder,
size: 12,
color: colorScheme.onTertiaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(width: 4),
Text(
'${album.tracks.length}',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
],
],
),
),
),
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
ClickableArtistName(
artistName: album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 8),
Text(
album.albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
],
ClickableArtistName(
artistName: album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -4356,13 +4373,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim();
final settings = ref.read(settingsProvider);
final localLibraryPath = settings.localLibraryPath.trim();
final iosBookmark = settings.localLibraryBookmark;
try {
if (localLibraryPath.isNotEmpty &&
!ref.read(localLibraryProvider).isScanning) {
await ref
.read(localLibraryProvider.notifier)
.startScan(localLibraryPath);
.startScan(
localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
@@ -4996,6 +5018,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -5275,18 +5300,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
return Semantics(
label: 'Finalizing download',
child: SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
),
ExcludeSemantics(
child: Icon(
Icons.edit_note,
color: colorScheme.tertiary,
size: 16,
),
),
],
),
),
);
case DownloadStatus.completed:
@@ -5314,18 +5348,32 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
)
else
Icon(Icons.error_outline, color: colorScheme.error, size: 20),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
Semantics(
label: 'Downloaded file missing',
child: ExcludeSemantics(
child: Icon(
Icons.error_outline,
color: colorScheme.error,
size: 20,
),
),
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: 20,
const SizedBox(width: 4),
Semantics(
label: 'Download completed',
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
),
),
],
@@ -5888,27 +5936,34 @@ class _QueueTabState extends ConsumerState<QueueTab> {
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
return fileExists
? GestureDetector(
onTap: () => _openFile(
item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
coverUrl:
item.coverUrl ??
item.localCoverPath ??
'',
),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
? Semantics(
button: true,
label:
'Play ${item.trackName} by ${item.artistName}',
child: GestureDetector(
onTap: () => _openFile(
item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
coverUrl:
item.coverUrl ??
item.localCoverPath ??
'',
),
child: Icon(
Icons.play_arrow,
color: colorScheme.onPrimary,
size: 16,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: ExcludeSemantics(
child: Icon(
Icons.play_arrow,
color: colorScheme.onPrimary,
size: 16,
),
),
),
),
)
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
@@ -311,9 +312,12 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
final orphanedDownloads = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
final iosBookmark = ref.read(settingsProvider).localLibraryBookmark;
final missingLibraryEntries = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
.cleanupMissingFiles(
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -384,11 +388,13 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
@@ -132,7 +132,23 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
// Fallback for older devices
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
if (Platform.isIOS) {
// On iOS, create a security-scoped bookmark so we can access
// this folder across app restarts and from the Go backend.
final bookmark =
await PlatformBridge.createIosBookmarkFromPath(result);
if (bookmark != null && bookmark.isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setLocalLibraryPathAndBookmark(result, bookmark);
} else {
// Bookmark creation failed; save path anyway (works for
// app-internal folders like Documents/).
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
} else {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
}
}
}
@@ -140,6 +156,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
Future<void> _startScan({bool forceFullScan = false}) async {
final settings = ref.read(settingsProvider);
final libraryPath = settings.localLibraryPath;
final iosBookmark = settings.localLibraryBookmark;
if (libraryPath.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -148,7 +165,14 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
if (!libraryPath.startsWith('content://') &&
// On iOS with a bookmark, try resolving the bookmark first to validate
// access instead of checking the path directly (which may fail outside
// the app sandbox).
if (Platform.isIOS && iosBookmark.isNotEmpty) {
// Bookmark will be resolved inside startScan; skip Directory.exists
// check since security-scoped paths are not accessible without the
// bookmark being activated.
} else if (!libraryPath.startsWith('content://') &&
!await Directory(libraryPath).exists()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -158,9 +182,11 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
await ref
.read(localLibraryProvider.notifier)
.startScan(libraryPath, forceFullScan: forceFullScan);
await ref.read(localLibraryProvider.notifier).startScan(
libraryPath,
forceFullScan: forceFullScan,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
}
Future<void> _cancelScan() async {
@@ -200,9 +226,12 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
Future<void> _cleanupMissingFiles() async {
final iosBookmark = ref.read(settingsProvider).localLibraryBookmark;
final removed = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
.cleanupMissingFiles(
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -230,6 +259,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
+41
View File
@@ -1155,6 +1155,47 @@ class PlatformBridge {
await _channel.invokeMethod('cancelLibraryScan');
}
// MARK: - iOS Security-Scoped 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.
static Future<String?> createIosBookmarkFromPath(String path) async {
try {
final result = await _channel.invokeMethod('createIosBookmarkFromPath', {
'path': path,
});
return result as String?;
} catch (e) {
_log.w('Failed to create iOS bookmark from path: $e');
return null;
}
}
/// Resolve a base64-encoded iOS security-scoped bookmark and start accessing
/// the resource. Returns the resolved filesystem path.
/// The resource stays accessed until [stopAccessingIosBookmark] is called.
static Future<String?> startAccessingIosBookmark(String bookmark) async {
try {
final result = await _channel.invokeMethod('startAccessingIosBookmark', {
'bookmark': bookmark,
});
return result as String?;
} catch (e) {
_log.w('Failed to start accessing iOS bookmark: $e');
return null;
}
}
/// Stop accessing the currently active iOS security-scoped resource.
static Future<void> stopAccessingIosBookmark() async {
try {
await _channel.invokeMethod('stopAccessingIosBookmark');
} catch (e) {
_log.w('Failed to stop accessing iOS bookmark: $e');
}
}
/// Read metadata from a single audio file
static Future<Map<String, dynamic>?> readAudioMetadata(
String filePath,