diff --git a/CHANGELOG.md b/CHANGELOG.md index 997ecf26..558512ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [3.0.0-alpha.3] - Upcoming + +### Added + +- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders + - Based on `album_type` from Spotify/Deezer metadata + - Toggle in Settings > Download > Separate Singles Folder + - Singles saved to `{output}/Singles/`, albums to `{output}/Albums/` + +### Performance + +- **Parallel API Calls**: Download URL fetching now uses parallel requests + - Tidal: All 8 APIs requested simultaneously, first success wins + - Qobuz: Both APIs requested simultaneously, first success wins + - Significantly reduces download URL fetch time + +### Fixed + +- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track + - Detects existing entries by Spotify ID, Deezer ID, or ISRC + - Replaces existing entry and moves to top of list + - Auto-deduplicates existing history on app load +- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search + - Now checks if extension is still enabled before calling custom search + - Auto-resets search provider to default if extension was disabled +- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error + - Now shows proper message: "Cannot write to folder, check storage permission" + - Added `permission` error type detection in backend +- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+ + - Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO` + - `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear) + - `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear) + - Proper permission check before showing "granted" status + +--- + ## [3.0.0-alpha.2] - 2026-01-12 ### Added diff --git a/go_backend/exports.go b/go_backend/exports.go index 19025dbc..b71cc2e0 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1015,6 +1015,12 @@ func errorResponse(msg string) (string, error) { strings.Contains(lowerMsg, "try using vpn") || strings.Contains(lowerMsg, "change dns") { errorType = "isp_blocked" + } else if strings.Contains(lowerMsg, "permission") || + strings.Contains(lowerMsg, "operation not permitted") || + strings.Contains(lowerMsg, "access denied") || + strings.Contains(lowerMsg, "failed to create file") || + strings.Contains(lowerMsg, "failed to create directory") { + errorType = "permission" } else if strings.Contains(lowerMsg, "not found") || strings.Contains(lowerMsg, "not available") || strings.Contains(lowerMsg, "no results") || diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index cf109142..2deb8f8c 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -19,6 +19,7 @@ enum DownloadErrorType { notFound, // Track not found on any service rateLimit, // Rate limited by service network, // Network/connection error + permission, // File/folder permission error } @JsonSerializable() @@ -88,6 +89,8 @@ class DownloadItem { return 'Rate limit reached, try again later'; case DownloadErrorType.network: return 'Connection failed, check your internet'; + case DownloadErrorType.permission: + return 'Cannot write to folder, check storage permission'; default: return error ?? 'An error occurred'; } diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 5dcb97f0..815ad996 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = { DownloadErrorType.notFound: 'notFound', DownloadErrorType.rateLimit: 'rateLimit', DownloadErrorType.network: 'network', + DownloadErrorType.permission: 'permission', }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 6bf6d6ce..69b172d1 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1792,6 +1792,9 @@ class DownloadQueueNotifier extends Notifier { case 'network': errorType = DownloadErrorType.network; break; + case 'permission': + errorType = DownloadErrorType.permission; + break; default: errorType = DownloadErrorType.unknown; } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 261f28c1..b4a8fd7e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -66,24 +66,38 @@ class _SetupScreenState extends ConsumerState { } } else if (Platform.isAndroid) { // Check storage permission - PermissionStatus storageStatus; + bool storageGranted = false; + if (_androidSdkVersion >= 33) { - storageStatus = await Permission.audio.status; + // Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO + final manageStatus = await Permission.manageExternalStorage.status; + final audioStatus = await Permission.audio.status; + debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus'); + storageGranted = manageStatus.isGranted && audioStatus.isGranted; } else if (_androidSdkVersion >= 30) { - storageStatus = await Permission.manageExternalStorage.status; + // Android 11-12: Need MANAGE_EXTERNAL_STORAGE only + final manageStatus = await Permission.manageExternalStorage.status; + debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus'); + storageGranted = manageStatus.isGranted; } else { - storageStatus = await Permission.storage.status; + // Android 10 and below: Use legacy storage permission + final storageStatus = await Permission.storage.status; + debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus'); + storageGranted = storageStatus.isGranted; } + debugPrint('[Permission] Final storageGranted=$storageGranted'); + // Check notification permission (Android 13+) PermissionStatus notificationStatus = PermissionStatus.granted; if (_androidSdkVersion >= 33) { notificationStatus = await Permission.notification.status; + debugPrint('[Permission] Notification=$notificationStatus'); } if (mounted) { setState(() { - _storagePermissionGranted = storageStatus.isGranted; + _storagePermissionGranted = storageGranted; _notificationPermissionGranted = notificationStatus.isGranted; }); } @@ -97,17 +111,57 @@ class _SetupScreenState extends ConsumerState { if (Platform.isIOS) { setState(() => _storagePermissionGranted = true); } else if (Platform.isAndroid) { - PermissionStatus status; + bool allGranted = false; if (_androidSdkVersion >= 33) { - // Android 13+: Use audio permission - status = await Permission.audio.request(); + // Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO + + // First check/request MANAGE_EXTERNAL_STORAGE + var manageStatus = await Permission.manageExternalStorage.status; + if (!manageStatus.isGranted) { + if (mounted) { + final shouldOpen = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Storage Access Required'), + content: const Text( + 'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n' + 'Please enable "Allow access to manage all files" in the next screen.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Open Settings'), + ), + ], + ), + ); + + if (shouldOpen == true) { + await Permission.manageExternalStorage.request(); + // Re-check after returning from settings + await Future.delayed(const Duration(milliseconds: 500)); + manageStatus = await Permission.manageExternalStorage.status; + } + } + } + + // Then request READ_MEDIA_AUDIO (this shows a dialog) + var audioStatus = await Permission.audio.status; + if (!audioStatus.isGranted && manageStatus.isGranted) { + audioStatus = await Permission.audio.request(); + } + + allGranted = manageStatus.isGranted && audioStatus.isGranted; + } else if (_androidSdkVersion >= 30) { - // Android 11-12: Need MANAGE_EXTERNAL_STORAGE - // This opens system settings, not a dialog - status = await Permission.manageExternalStorage.status; - if (!status.isGranted) { - // Show explanation dialog first + // Android 11-12: Need MANAGE_EXTERNAL_STORAGE only + var manageStatus = await Permission.manageExternalStorage.status; + if (!manageStatus.isGranted) { if (mounted) { final shouldOpen = await showDialog( context: context, @@ -131,23 +185,33 @@ class _SetupScreenState extends ConsumerState { ); if (shouldOpen == true) { - status = await Permission.manageExternalStorage.request(); + await Permission.manageExternalStorage.request(); + // Re-check after returning from settings + await Future.delayed(const Duration(milliseconds: 500)); + manageStatus = await Permission.manageExternalStorage.status; } } } + allGranted = manageStatus.isGranted; + } else { // Android 10 and below: Use legacy storage permission - status = await Permission.storage.request(); + final status = await Permission.storage.request(); + allGranted = status.isGranted; + + if (status.isPermanentlyDenied) { + _showPermissionDeniedDialog('Storage'); + setState(() => _isLoading = false); + return; + } } - if (status.isGranted) { + if (allGranted) { setState(() => _storagePermissionGranted = true); - } else if (status.isPermanentlyDenied) { - _showPermissionDeniedDialog('Storage'); } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Permission denied. Please grant permission to continue.')), + const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')), ); } }