From d4177436547e730eb7c9878ad6faa6bef329f6ec Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 18:47:53 +0700 Subject: [PATCH 1/2] chore: bump version to 2.2.9 --- CHANGELOG.md | 11 +++++++++++ lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba7038b..8339396b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2.2.9] - 2026-01-12 + +### 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 + +--- + ## [2.2.8] - 2026-01-12 ### Added diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index add92180..d1e4cace 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.2.8'; - static const String buildNumber = '50'; + static const String version = '2.2.9'; + static const String buildNumber = '51'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index 60513023..75d62c55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.8+50 +version: 2.2.9+51 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 1c08e30c..847080c9 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.8+50 +version: 2.2.9+51 environment: sdk: ^3.10.0 From d98960d053ffdd2665da29cff15139e58734b2d1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 19:56:12 +0700 Subject: [PATCH 2/2] fix: permission error message and Android 13+ storage permission - Fixed download showing 'Song not found' when actually permission error - Added permission error type detection in Go backend - Android 13+ now requests both MANAGE_EXTERNAL_STORAGE and READ_MEDIA_AUDIO - MANAGE_EXTERNAL_STORAGE opens Settings (system-level) - READ_MEDIA_AUDIO shows dialog (app-level, resets on clear data) - Proper permission check before showing 'granted' status --- CHANGELOG.md | 8 ++ go_backend/exports.go | 6 ++ lib/models/download_item.dart | 3 + lib/models/download_item.g.dart | 1 + lib/providers/download_queue_provider.dart | 3 + lib/screens/setup_screen.dart | 102 +++++++++++++++++---- 6 files changed, 104 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8339396b..4b0292bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ - 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 +- **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 --- diff --git a/go_backend/exports.go b/go_backend/exports.go index 98a9b978..1d0c822a 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -991,6 +991,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 c1c56f63..7d75543d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1677,6 +1677,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 3014da53..2060360e 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.')), ); } }