feat: add Storage Access Framework (SAF) support for Android 10+

- Add SAF tree picker and persistent URI storage in settings
- Implement SAF file operations: exists, delete, stat, copy, create
- Update download pipeline to support SAF content URIs
- Add fallback to app-private storage when SAF write fails
- Support SAF in library scan with DocumentFile traversal
- Add history item repair for missing SAF URIs
- Create file_access.dart utilities for abstracted file operations
- Update Tidal/Qobuz/Amazon/Extensions for SAF-aware output
- Add runPostProcessingV2 API for SAF content URIs
- Update screens (album, artist, queue, track) for SAF awareness

Resolves Android 10+ scoped storage permission issues
This commit is contained in:
zarzet
2026-02-06 07:09:57 +07:00
parent 7ade57e010
commit 278ebf3472
30 changed files with 2489 additions and 389 deletions
+9 -1
View File
@@ -8,6 +8,8 @@ class AppSettings {
final String audioQuality;
final String filenameFormat;
final String downloadDirectory;
final String storageMode; // 'app' or 'saf'
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool embedLyrics;
final bool maxQualityCover;
@@ -32,7 +34,7 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
@@ -47,6 +49,8 @@ class AppSettings {
this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '',
this.storageMode = 'app',
this.downloadTreeUri = '',
this.autoFallback = true,
this.embedLyrics = true,
this.maxQualityCover = true,
@@ -86,6 +90,8 @@ class AppSettings {
String? audioQuality,
String? filenameFormat,
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? embedLyrics,
bool? maxQualityCover,
@@ -125,6 +131,8 @@ class AppSettings {
audioQuality: audioQuality ?? this.audioQuality,
filenameFormat: filenameFormat ?? this.filenameFormat,
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback,
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
+4
View File
@@ -11,6 +11,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
storageMode: json['storageMode'] as String? ?? 'app',
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
@@ -54,6 +56,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'audioQuality': instance.audioQuality,
'filenameFormat': instance.filenameFormat,
'downloadDirectory': instance.downloadDirectory,
'storageMode': instance.storageMode,
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -17,6 +17,7 @@ class LocalLibraryState {
final String? scanCurrentFile;
final int scanTotalFiles;
final int scanErrorCount;
final bool scanWasCancelled;
final DateTime? lastScannedAt;
final Set<String> _isrcSet;
final Set<String> _trackKeySet;
@@ -29,6 +30,7 @@ class LocalLibraryState {
this.scanCurrentFile,
this.scanTotalFiles = 0,
this.scanErrorCount = 0,
this.scanWasCancelled = false,
this.lastScannedAt,
}) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
@@ -72,6 +74,7 @@ class LocalLibraryState {
String? scanCurrentFile,
int? scanTotalFiles,
int? scanErrorCount,
bool? scanWasCancelled,
DateTime? lastScannedAt,
}) {
return LocalLibraryState(
@@ -81,6 +84,7 @@ class LocalLibraryState {
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
);
}
@@ -90,6 +94,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
Timer? _progressTimer;
bool _isLoaded = false;
bool _scanCancelRequested = false;
@override
LocalLibraryState build() {
@@ -142,6 +147,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
_scanCancelRequested = false;
_log.i('Starting library scan: $folderPath');
state = state.copyWith(
isScanning: true,
@@ -149,6 +155,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanCurrentFile: null,
scanTotalFiles: 0,
scanErrorCount: 0,
scanWasCancelled: false,
);
try {
@@ -163,7 +170,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
try {
final results = await PlatformBridge.scanLibraryFolder(folderPath);
final isSaf = folderPath.startsWith('content://');
final results = isSaf
? await PlatformBridge.scanSafTree(folderPath)
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
return;
}
final items = <LocalLibraryItem>[];
for (final json in results) {
@@ -187,12 +201,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
);
_log.i('Scan complete: ${items.length} tracks found');
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false);
state = state.copyWith(isScanning: false, scanWasCancelled: false);
} finally {
_stopProgressPolling();
}
@@ -227,8 +242,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (!state.isScanning) return;
_log.i('Cancelling library scan');
_scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false);
state = state.copyWith(isScanning: false, scanWasCancelled: true);
_stopProgressPolling();
}
+19
View File
@@ -48,6 +48,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
}
}
@@ -120,6 +123,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setStorageMode(String mode) {
final normalized = mode == 'saf' ? 'saf' : 'app';
state = state.copyWith(storageMode: normalized);
_saveSettings();
}
void setDownloadTreeUri(String uri, {String? displayName}) {
final nextDisplay = displayName ?? state.downloadDirectory;
state = state.copyWith(
downloadTreeUri: uri,
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
downloadDirectory: nextDisplay,
);
_saveSettings();
}
void setAutoFallback(bool enabled) {
state = state.copyWith(autoFallback: enabled);
_saveSettings();
+3 -3
View File
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -12,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
@@ -695,8 +695,8 @@ child: ListTile(
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
+3 -3
View File
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -14,6 +13,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -1061,8 +1061,8 @@ if (hasValidImage)
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
+3 -9
View File
@@ -1,13 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -180,10 +178,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
await deleteFile(item.filePath);
} catch (_) {}
historyNotifier.removeFromHistory(id);
deletedCount++;
@@ -202,8 +197,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Future<void> _openFile(String filePath) async {
try {
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
await openFile(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+3 -2
View File
@@ -19,6 +19,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -2569,8 +2570,8 @@ class _TrackItemWithStatus extends ConsumerWidget {
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
+3 -8
View File
@@ -2,9 +2,8 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
@@ -192,10 +191,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
await deleteFile(item.filePath);
} catch (_) {}
libraryNotifier.removeItem(id);
deletedCount++;
@@ -219,8 +215,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Future<void> _openFile(String filePath) async {
try {
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
await openFile(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+3 -3
View File
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -9,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -512,8 +512,8 @@ leading: track.coverUrl != null
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
}
+86 -9
View File
@@ -5,10 +5,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -279,6 +278,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String _localFilterQueryCache = '';
List<LocalLibraryItem> _filteredLocalItemsCache = const [];
final Map<String, _UnifiedCacheEntry> _unifiedItemsCache = {};
bool _showSafRepairedBadge = false;
// Advanced filters
String? _filterSource; // null = all, 'downloaded', 'local'
@@ -637,10 +637,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (item != null) {
try {
final cleanPath = _cleanFilePath(item.filePath);
final file = File(cleanPath);
if (await file.exists()) {
await file.delete();
}
await deleteFile(cleanPath);
} catch (_) {}
// Remove from appropriate database
@@ -695,7 +692,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
_pendingChecks.add(cleanPath);
Future.microtask(() async {
final exists = await File(cleanPath).exists();
final exists = await fileExists(cleanPath);
_pendingChecks.remove(cleanPath);
final previous = _fileExistsCache[cleanPath];
_fileExistsCache[cleanPath] = exists;
@@ -1020,8 +1017,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
Future<void> _openFile(String filePath) async {
final cleanPath = _cleanFilePath(filePath);
try {
final mimeType = audioMimeTypeForPath(cleanPath);
await OpenFilex.open(cleanPath, type: mimeType);
await openFile(cleanPath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
@@ -1315,6 +1311,7 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
final groupedLocalAlbums = historyStats.groupedLocalAlbums;
final albumCount = historyStats.totalAlbumCount;
final singleCount = historyStats.totalSingleTracks;
final hasSafRepairedItems = allHistoryItems.any((item) => item.safRepaired);
final bottomPadding = MediaQuery.of(context).padding.bottom;
@@ -1584,6 +1581,7 @@ const Spacer(),
albumCounts: historyStats.albumCounts,
localAlbumCounts: historyStats.localAlbumCounts,
localLibraryItems: localLibraryItems,
hasSafRepairedItems: hasSafRepairedItems,
);
},
),
@@ -1705,6 +1703,7 @@ child: _buildSelectionBottomBar(
required Map<String, int> albumCounts,
required Map<String, int> localAlbumCounts,
required List<LocalLibraryItem> localLibraryItems,
required bool hasSafRepairedItems,
}) {
final historyItems = _resolveHistoryItems(
filterMode: filterMode,
@@ -1780,6 +1779,27 @@ child: _buildSelectionBottomBar(
),
),
),
if (!_isSelectionMode && hasSafRepairedItems)
IconButton(
onPressed: () {
setState(() {
_showSafRepairedBadge = !_showSafRepairedBadge;
});
},
icon: Icon(
_showSafRepairedBadge ? Icons.build : Icons.build_outlined,
size: 18,
),
tooltip: _showSafRepairedBadge
? 'Hide SAF repaired badge'
: 'Show SAF repaired badge',
style: IconButton.styleFrom(
backgroundColor: _showSafRepairedBadge
? colorScheme.tertiaryContainer.withValues(alpha: 0.6)
: colorScheme.surfaceContainerHighest,
visualDensity: VisualDensity.compact,
),
),
if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty)
TextButton.icon(
onPressed: () => _enterSelectionMode(filteredUnifiedItems.first.id),
@@ -2911,6 +2931,10 @@ child: CachedNetworkImage(
final sourceTextColor = isDownloaded
? colorScheme.onPrimaryContainer
: colorScheme.onSecondaryContainer;
final showSafRepaired = _showSafRepairedBadge &&
isDownloaded &&
item.historyItem != null &&
item.historyItem!.safRepaired;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
@@ -3007,6 +3031,38 @@ child: CachedNetworkImage(
),
),
),
if (showSafRepaired) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.build,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
'SAF repaired',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onTertiaryContainer,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
const SizedBox(width: 8),
Text(
dateStr,
@@ -3092,6 +3148,10 @@ child: CachedNetworkImage(
final fileExists = _checkFileExists(item.filePath);
final isSelected = _selectedIds.contains(item.id);
final isDownloaded = item.source == LibraryItemSource.downloaded;
final showSafRepaired = _showSafRepairedBadge &&
isDownloaded &&
item.historyItem != null &&
item.historyItem!.safRepaired;
return GestureDetector(
onTap: _isSelectionMode
@@ -3168,6 +3228,23 @@ child: CachedNetworkImage(
),
),
),
if (showSafRepaired)
Positioned(
left: 4,
bottom: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.build,
size: 12,
color: colorScheme.onTertiaryContainer,
),
),
),
if (fileExists && !_isSelectionMode)
Positioned(
right: 4,
@@ -8,6 +8,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget {
@@ -680,9 +681,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (Platform.isIOS) {
_showIOSDirectoryOptions(context, ref);
} else {
final result = await FilePicker.platform.getDirectoryPath();
final result = await PlatformBridge.pickSafTree();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
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,
);
}
}
}
}
@@ -899,6 +908,8 @@ ListTile(
switch (format) {
case 'mp3_320':
return 'MP3 320kbps';
case 'opus_256':
return 'Opus 256kbps';
case 'opus_128':
return 'Opus 128kbps';
default:
@@ -951,10 +962,20 @@ ListTile(
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: current == 'opus_256' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
@@ -1167,6 +1188,7 @@ class _ServiceSelector extends ConsumerWidget {
label: 'Amazon',
isSelected: effectiveService == 'amazon',
isDisabled: true,
disabledReason: 'Coming soon',
onTap: () {},
),
],
@@ -117,7 +117,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
if (!await Directory(libraryPath).exists()) {
if (!libraryPath.startsWith('content://') &&
!await Directory(libraryPath).exists()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryFolderNotExist)),
@@ -290,6 +291,53 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
),
if (libraryState.scanWasCancelled)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Scan cancelled',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
),
const SizedBox(height: 2),
Text(
'You can retry the scan when ready.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8),
),
),
],
),
),
TextButton(
onPressed: _startScan,
child: Text(context.l10n.dialogRetry),
),
],
),
),
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
+32 -12
View File
@@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key});
@@ -22,6 +23,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false;
String? _selectedDirectory;
String? _selectedTreeUri;
bool _isLoading = false;
int _androidSdkVersion = 0;
@@ -246,13 +248,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (Platform.isIOS) {
await _showIOSDirectoryOptions();
} else {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: context.l10n.setupSelectDownloadFolder,
);
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) {
setState(() {
_selectedTreeUri = treeUri;
_selectedDirectory = displayName.isNotEmpty ? displayName : treeUri;
});
}
}
if (selectedDirectory != null) {
setState(() => _selectedDirectory = selectedDirectory);
} else {
if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
@@ -268,7 +276,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
setState(() {
_selectedTreeUri = '';
_selectedDirectory = defaultDir;
});
}
}
}
@@ -387,12 +398,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
setState(() => _isLoading = true);
try {
final dir = Directory(_selectedDirectory!);
if (!await dir.exists()) {
await dir.create(recursive: true);
if (!Platform.isAndroid || _selectedTreeUri == null || _selectedTreeUri!.isEmpty) {
final dir = Directory(_selectedDirectory!);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
ref.read(settingsProvider.notifier).setStorageMode('app');
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
ref.read(settingsProvider.notifier).setDownloadTreeUri('');
} else {
ref.read(settingsProvider.notifier).setStorageMode('saf');
ref.read(settingsProvider.notifier).setDownloadTreeUri(
_selectedTreeUri!,
displayName: _selectedDirectory,
);
}
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
if (_useSpotifyApi &&
_clientIdController.text.trim().isNotEmpty &&
+23 -23
View File
@@ -3,11 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -128,9 +127,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool exists = false;
int? size;
try {
final stat = await FileStat.stat(filePath);
exists = stat.type != FileSystemEntityType.notFound;
if (exists) {
final stat = await fileStat(filePath);
if (stat != null) {
exists = true;
size = stat.size;
}
} catch (_) {}
@@ -1212,10 +1211,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (_isLocalItem) {
// For local items, just delete the file
try {
final file = File(cleanFilePath);
if (await file.exists()) {
await file.delete();
}
await deleteFile(cleanFilePath);
} catch (e) {
debugPrint('Failed to delete file: $e');
}
@@ -1224,10 +1220,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} else {
// Existing download history deletion logic
try {
final file = File(cleanFilePath);
if (await file.exists()) {
await file.delete();
}
await deleteFile(cleanFilePath);
} catch (e) {
debugPrint('Failed to delete file: $e');
}
@@ -1249,13 +1242,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Future<void> _openFile(BuildContext context, String filePath) async {
try {
final mimeType = audioMimeTypeForPath(filePath);
final result = await OpenFilex.open(filePath, type: mimeType);
if (result.type != ResultType.done && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
);
}
await openFile(filePath);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1276,8 +1263,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
Future<void> _shareFile(BuildContext context) async {
final file = File(cleanFilePath);
if (!await file.exists()) {
String sharePath = cleanFilePath;
if (!await fileExists(sharePath)) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
@@ -1285,10 +1272,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
return;
}
if (isContentUri(sharePath)) {
final tempPath = await PlatformBridge.copyContentUriToTemp(sharePath);
if (tempPath == null || tempPath.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to prepare file for sharing'))),
);
}
return;
}
sharePath = tempPath;
}
await SharePlus.instance.share(
ShareParams(
files: [XFile(cleanFilePath)],
files: [XFile(sharePath)],
text: '$trackName - $artistName',
),
);
+25 -2
View File
@@ -34,7 +34,7 @@ class HistoryDatabase {
return await openDatabase(
path,
version: 1,
version: 3,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -52,6 +52,11 @@ class HistoryDatabase {
album_artist TEXT,
cover_url TEXT,
file_path TEXT NOT NULL,
storage_mode TEXT,
download_tree_uri TEXT,
saf_relative_dir TEXT,
saf_file_name TEXT,
saf_repaired INTEGER,
service TEXT NOT NULL,
downloaded_at TEXT NOT NULL,
isrc TEXT,
@@ -80,7 +85,15 @@ class HistoryDatabase {
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
// Future migrations go here
if (oldVersion < 2) {
await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT');
await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
}
}
// ==================== iOS Path Normalization ====================
@@ -244,6 +257,11 @@ class HistoryDatabase {
'album_artist': json['albumArtist'],
'cover_url': json['coverUrl'],
'file_path': json['filePath'],
'storage_mode': json['storageMode'],
'download_tree_uri': json['downloadTreeUri'],
'saf_relative_dir': json['safRelativeDir'],
'saf_file_name': json['safFileName'],
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
'service': json['service'],
'downloaded_at': json['downloadedAt'],
'isrc': json['isrc'],
@@ -272,6 +290,11 @@ class HistoryDatabase {
'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'],
'filePath': _normalizeIosPath(row['file_path'] as String?),
'storageMode': row['storage_mode'],
'downloadTreeUri': row['download_tree_uri'],
'safRelativeDir': row['saf_relative_dir'],
'safFileName': row['saf_file_name'],
'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true,
'service': row['service'],
'downloadedAt': row['downloaded_at'],
'isrc': row['isrc'],
+2 -2
View File
@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/file_access.dart';
final _log = AppLogger('LibraryDatabase');
@@ -341,7 +341,7 @@ class LibraryDatabase {
int removed = 0;
for (final row in rows) {
final filePath = row['file_path'] as String;
if (!await File(filePath).exists()) {
if (!await fileExists(filePath)) {
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
removed++;
}
+130
View File
@@ -67,6 +67,11 @@ class PlatformBridge {
String? releaseDate,
String? itemId,
int durationMs = 0,
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadTrack: "$trackName" by $artistName via $service');
final request = jsonEncode({
@@ -89,6 +94,11 @@ class PlatformBridge {
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadTrack', request);
@@ -125,6 +135,11 @@ class PlatformBridge {
String? label,
String? copyright,
String lyricsMode = 'embed',
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
final request = jsonEncode({
@@ -151,6 +166,11 @@ class PlatformBridge {
'label': label ?? '',
'copyright': copyright ?? '',
'lyrics_mode': lyricsMode,
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadWithFallback', request);
@@ -225,6 +245,80 @@ class PlatformBridge {
return result as String;
}
static Future<Map<String, dynamic>?> pickSafTree() async {
final result = await _channel.invokeMethod('pickSafTree');
if (result == null) return null;
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> safExists(String uri) async {
final result = await _channel.invokeMethod('safExists', {'uri': uri});
return result as bool;
}
static Future<bool> safDelete(String uri) async {
final result = await _channel.invokeMethod('safDelete', {'uri': uri});
return result as bool;
}
static Future<Map<String, dynamic>> safStat(String uri) async {
final result = await _channel.invokeMethod('safStat', {'uri': uri});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> resolveSafFile({
required String treeUri,
required String fileName,
String relativeDir = '',
}) async {
final result = await _channel.invokeMethod('resolveSafFile', {
'tree_uri': treeUri,
'relative_dir': relativeDir,
'file_name': fileName,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<String?> copyContentUriToTemp(String uri) async {
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
return result as String?;
}
static Future<bool> replaceContentUriFromPath(
String uri,
String srcPath,
) async {
final result = await _channel.invokeMethod('safReplaceFromPath', {
'uri': uri,
'src_path': srcPath,
});
return result as bool;
}
static Future<String?> createSafFileFromPath({
required String treeUri,
required String relativeDir,
required String fileName,
required String mimeType,
required String srcPath,
}) async {
final result = await _channel.invokeMethod('safCreateFromPath', {
'tree_uri': treeUri,
'relative_dir': relativeDir,
'file_name': fileName,
'mime_type': mimeType,
'src_path': srcPath,
});
return result as String?;
}
static Future<void> openContentUri(String uri, {String mimeType = ''}) async {
await _channel.invokeMethod('openContentUri', {
'uri': uri,
'mime_type': mimeType,
});
}
static Future<Map<String, dynamic>> fetchLyrics(
String spotifyId,
String trackName,
@@ -593,6 +687,11 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
String? label,
String lyricsMode = 'embed',
String? preferredService,
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}');
final request = jsonEncode({
@@ -619,6 +718,11 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
'label': label ?? '',
'lyrics_mode': lyricsMode,
'service': preferredService ?? '',
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadWithExtensions', request);
@@ -852,6 +956,15 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
_log.i('scanSafTree: $treeUri');
final result = await _channel.invokeMethod('scanSafTree', {
'tree_uri': treeUri,
});
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress');
@@ -889,6 +1002,23 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> runPostProcessingV2(
String filePath, {
Map<String, dynamic>? metadata,
}) async {
final input = <String, dynamic>{};
if (filePath.startsWith('content://')) {
input['uri'] = filePath;
} else {
input['path'] = filePath;
}
final result = await _channel.invokeMethod('runPostProcessingV2', {
'input': jsonEncode(input),
'metadata': metadata != null ? jsonEncode(metadata) : '',
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
final result = await _channel.invokeMethod('getPostProcessingProviders');
final list = jsonDecode(result as String) as List<dynamic>;
+66
View File
@@ -0,0 +1,66 @@
import 'dart:io';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
class FileAccessStat {
final int? size;
final DateTime? modified;
const FileAccessStat({this.size, this.modified});
}
bool isContentUri(String? path) {
return path != null && path.startsWith('content://');
}
Future<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
if (isContentUri(path)) {
return PlatformBridge.safExists(path);
}
return File(path).exists();
}
Future<void> deleteFile(String? path) async {
if (path == null || path.isEmpty) return;
if (isContentUri(path)) {
await PlatformBridge.safDelete(path);
return;
}
try {
await File(path).delete();
} catch (_) {}
}
Future<FileAccessStat?> fileStat(String? path) async {
if (path == null || path.isEmpty) return null;
if (isContentUri(path)) {
final stat = await PlatformBridge.safStat(path);
final exists = stat['exists'] as bool? ?? true;
if (!exists) return null;
return FileAccessStat(
size: stat['size'] as int?,
modified: stat['modified'] != null
? DateTime.fromMillisecondsSinceEpoch(stat['modified'] as int)
: null,
);
}
final stat = await FileStat.stat(path);
if (stat.type == FileSystemEntityType.notFound) return null;
return FileAccessStat(size: stat.size, modified: stat.modified);
}
Future<void> openFile(String path) async {
if (isContentUri(path)) {
await PlatformBridge.openContentUri(path, mimeType: '');
return;
}
final mimeType = audioMimeTypeForPath(path);
final result = await OpenFilex.open(path, type: mimeType);
if (result.type != ResultType.done) {
throw Exception(result.message);
}
}
+23 -1
View File
@@ -334,6 +334,28 @@ Padding(
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: currentFormat == 'opus_256'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
@@ -345,7 +367,7 @@ Padding(
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: currentFormat == 'opus_128'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,