Compare commits

..

3 Commits

Author SHA1 Message Date
zarzet d98960d053 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
2026-01-12 19:56:12 +07:00
zarzet d417743654 chore: bump version to 2.2.9 2026-01-12 18:47:53 +07:00
zarzet efbf5d4c5b fix: prevent duplicate entries in download history
- Add duplicate detection in addToHistory() by spotifyId, deezerId, or ISRC
- Replace existing entry and move to top when re-downloading same track
- Add _deduplicateHistory() to clean up existing duplicates on app load
- Auto-save after removing duplicates from storage

Fixes duplicate history entries when downloading same track multiple times
2026-01-12 18:25:38 +07:00
9 changed files with 213 additions and 26 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog # 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
- **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
---
## [2.2.8] - 2026-01-12 ## [2.2.8] - 2026-01-12
### Added ### Added
+6
View File
@@ -991,6 +991,12 @@ func errorResponse(msg string) (string, error) {
strings.Contains(lowerMsg, "try using vpn") || strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") { strings.Contains(lowerMsg, "change dns") {
errorType = "isp_blocked" 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") || } else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") || strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") || strings.Contains(lowerMsg, "no results") ||
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '2.2.8'; static const String version = '2.2.9';
static const String buildNumber = '50'; static const String buildNumber = '51';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+3
View File
@@ -19,6 +19,7 @@ enum DownloadErrorType {
notFound, // Track not found on any service notFound, // Track not found on any service
rateLimit, // Rate limited by service rateLimit, // Rate limited by service
network, // Network/connection error network, // Network/connection error
permission, // File/folder permission error
} }
@JsonSerializable() @JsonSerializable()
@@ -88,6 +89,8 @@ class DownloadItem {
return 'Rate limit reached, try again later'; return 'Rate limit reached, try again later';
case DownloadErrorType.network: case DownloadErrorType.network:
return 'Connection failed, check your internet'; return 'Connection failed, check your internet';
case DownloadErrorType.permission:
return 'Cannot write to folder, check storage permission';
default: default:
return error ?? 'An error occurred'; return error ?? 'An error occurred';
} }
+1
View File
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.notFound: 'notFound', DownloadErrorType.notFound: 'notFound',
DownloadErrorType.rateLimit: 'rateLimit', DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network', DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
}; };
+97 -3
View File
@@ -155,8 +155,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final items = jsonList final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)) .map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from storage'); // Deduplicate existing history on load
final deduplicatedItems = _deduplicateHistory(items);
state = state.copyWith(items: deduplicatedItems);
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
// Save if duplicates were removed
if (deduplicatedItems.length < items.length) {
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
await _saveToStorage();
}
} else { } else {
_historyLog.d('No history found in storage'); _historyLog.d('No history found in storage');
} }
@@ -165,6 +175,46 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
} }
/// Deduplicate history items by spotifyId, deezerId, or ISRC
/// Keeps the most recent entry (first occurrence since list is sorted by date desc)
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
final seen = <String, int>{}; // key -> index of first occurrence
final result = <DownloadHistoryItem>[];
for (int i = 0; i < items.length; i++) {
final item = items[i];
String? key;
// Generate unique key based on available identifiers
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
// Extract numeric ID for deezer: prefixed IDs
if (item.spotifyId!.startsWith('deezer:')) {
key = 'deezer:${item.spotifyId!.substring(7)}';
} else {
key = 'spotify:${item.spotifyId}';
}
} else if (item.isrc != null && item.isrc!.isNotEmpty) {
key = 'isrc:${item.isrc}';
}
if (key != null) {
if (!seen.containsKey(key)) {
// First occurrence - keep it (most recent since list is sorted by date desc)
seen[key] = result.length;
result.add(item);
} else {
// Duplicate found - skip (keep the first/most recent one)
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
}
} else {
// No identifier - keep it (can't deduplicate)
result.add(item);
}
}
return result;
}
Future<void> _saveToStorage() async { Future<void> _saveToStorage() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -182,7 +232,48 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
void addToHistory(DownloadHistoryItem item) { void addToHistory(DownloadHistoryItem item) {
state = state.copyWith(items: [item, ...state.items]); // Check if track already exists in history (by spotifyId, deezerId, or ISRC)
final existingIndex = state.items.indexWhere((existing) {
// Match by spotifyId (primary identifier - includes deezer:xxx format)
if (item.spotifyId != null &&
item.spotifyId!.isNotEmpty &&
existing.spotifyId == item.spotifyId) {
return true;
}
// Match Deezer tracks: extract numeric ID from "deezer:123456" format
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
final itemDeezerId = item.spotifyId!.substring(7); // Remove "deezer:" prefix
final existingDeezerId = existing.spotifyId!.substring(7);
if (itemDeezerId == existingDeezerId) {
return true;
}
}
// Fallback: match by ISRC if spotifyId not available
if (item.isrc != null &&
item.isrc!.isNotEmpty &&
existing.isrc == item.isrc) {
return true;
}
return false;
});
if (existingIndex >= 0) {
// Replace existing entry (update with new download info)
final updatedItems = [...state.items];
updatedItems[existingIndex] = item;
// Move to top of list (most recent)
updatedItems.removeAt(existingIndex);
updatedItems.insert(0, item);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
} else {
// Add new entry
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
}
_saveToStorage(); _saveToStorage();
} }
@@ -1586,6 +1677,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case 'network': case 'network':
errorType = DownloadErrorType.network; errorType = DownloadErrorType.network;
break; break;
case 'permission':
errorType = DownloadErrorType.permission;
break;
default: default:
errorType = DownloadErrorType.unknown; errorType = DownloadErrorType.unknown;
} }
+83 -19
View File
@@ -66,24 +66,38 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} }
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
// Check storage permission // Check storage permission
PermissionStatus storageStatus; bool storageGranted = false;
if (_androidSdkVersion >= 33) { 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) { } 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 { } 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+) // Check notification permission (Android 13+)
PermissionStatus notificationStatus = PermissionStatus.granted; PermissionStatus notificationStatus = PermissionStatus.granted;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
notificationStatus = await Permission.notification.status; notificationStatus = await Permission.notification.status;
debugPrint('[Permission] Notification=$notificationStatus');
} }
if (mounted) { if (mounted) {
setState(() { setState(() {
_storagePermissionGranted = storageStatus.isGranted; _storagePermissionGranted = storageGranted;
_notificationPermissionGranted = notificationStatus.isGranted; _notificationPermissionGranted = notificationStatus.isGranted;
}); });
} }
@@ -97,17 +111,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (Platform.isIOS) { if (Platform.isIOS) {
setState(() => _storagePermissionGranted = true); setState(() => _storagePermissionGranted = true);
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
PermissionStatus status; bool allGranted = false;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
// Android 13+: Use audio permission // Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
status = await Permission.audio.request();
// First check/request MANAGE_EXTERNAL_STORAGE
var manageStatus = await Permission.manageExternalStorage.status;
if (!manageStatus.isGranted) {
if (mounted) {
final shouldOpen = await showDialog<bool>(
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) { } else if (_androidSdkVersion >= 30) {
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE // Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
// This opens system settings, not a dialog var manageStatus = await Permission.manageExternalStorage.status;
status = await Permission.manageExternalStorage.status; if (!manageStatus.isGranted) {
if (!status.isGranted) {
// Show explanation dialog first
if (mounted) { if (mounted) {
final shouldOpen = await showDialog<bool>( final shouldOpen = await showDialog<bool>(
context: context, context: context,
@@ -131,23 +185,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
); );
if (shouldOpen == true) { 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 { } else {
// Android 10 and below: Use legacy storage permission // 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); setState(() => _storagePermissionGranted = true);
} else if (status.isPermanentlyDenied) {
_showPermissionDeniedDialog('Storage');
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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.')),
); );
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 2.2.8+50 version: 2.2.9+51
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 2.2.8+50 version: 2.2.9+51
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0