mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d98960d053 | |||
| d417743654 | |||
| efbf5d4c5b |
@@ -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
|
||||||
|
|||||||
@@ -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") ||
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user