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
## [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
### Added
+6
View File
@@ -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") ||
+2 -2
View File
@@ -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';
+3
View File
@@ -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';
}
+1
View File
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.notFound: 'notFound',
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
};
+97 -3
View File
@@ -155,8 +155,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
.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 {
_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 {
try {
final prefs = await SharedPreferences.getInstance();
@@ -182,7 +232,48 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
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();
}
@@ -1586,6 +1677,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case 'network':
errorType = DownloadErrorType.network;
break;
case 'permission':
errorType = DownloadErrorType.permission;
break;
default:
errorType = DownloadErrorType.unknown;
}
+83 -19
View File
@@ -66,24 +66,38 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
} 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<SetupScreen> {
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<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) {
// 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<bool>(
context: context,
@@ -131,23 +185,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
);
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.')),
);
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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