fix: persist download queue to survive app restart (v1.6.1)

- Download queue now persisted to SharedPreferences
- Auto-restore pending downloads on app restart
- Interrupted downloads reset to queued and auto-resumed
- singleTask launch mode to prevent app restart on share intent
- onNewIntent handler for proper intent handling
- Reverted share_plus to 10.1.4 (12.0.1 has Kotlin build issues)
This commit is contained in:
zarzet
2026-01-02 17:35:34 +07:00
parent bd9b527161
commit 39ddb7a14f
9 changed files with 114 additions and 17 deletions
+9 -1
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## [1.6.1] - 2026-01-02
### Fixed
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
- Download queue is now persisted to storage and automatically restored on app restart
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
- Changed launch mode to `singleTask` to reuse existing activity instead of restarting
- Added `onNewIntent` handler to properly receive new share intents
## [1.6.0] - 2026-01-02 ## [1.6.0] - 2026-01-02
### Added ### Added
@@ -33,7 +42,6 @@
### Improved ### Improved
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package - **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
- **Dependencies Updated**: - **Dependencies Updated**:
- `share_plus`: 10.1.4 → 12.0.1
- `flutter_local_notifications`: 18.0.1 → 19.0.0 - `flutter_local_notifications`: 18.0.1 → 19.0.0
- `build_runner`: 2.4.15 → 2.10.4 - `build_runner`: 2.4.15 → 2.10.4
+1 -2
View File
@@ -23,8 +23,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -1,5 +1,6 @@
package com.zarz.spotiflac package com.zarz.spotiflac
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@@ -14,6 +15,12 @@ class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend" private val CHANNEL = "com.zarz.spotiflac/backend"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
+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 = '1.6.0'; static const String version = '1.6.1';
static const String buildNumber = '25'; static const String buildNumber = '26';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
static const String appName = 'SpotiFLAC'; static const String appName = 'SpotiFLAC';
+86 -1
View File
@@ -242,18 +242,90 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer; Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup int _downloadCount = 0; // Counter for connection cleanup
static const _cleanupInterval = 50; // Cleanup every 50 downloads static const _cleanupInterval = 50; // Cleanup every 50 downloads
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
int _totalQueuedAtStart = 0; // Track total items when queue started int _totalQueuedAtStart = 0; // Track total items when queue started
bool _isLoaded = false;
@override @override
DownloadQueueState build() { DownloadQueueState build() {
// Initialize output directory asynchronously // Initialize output directory and load persisted queue asynchronously
Future.microtask(() async { Future.microtask(() async {
await _initOutputDir(); await _initOutputDir();
await _loadQueueFromStorage();
}); });
return const DownloadQueueState(); return const DownloadQueueState();
} }
/// Load persisted queue from storage (for app restart recovery)
Future<void> _loadQueueFromStorage() async {
if (_isLoaded) return;
_isLoaded = true;
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_queueStorageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList();
// Reset downloading items to queued (they were interrupted)
final restoredItems = items.map((item) {
if (item.status == DownloadStatus.downloading) {
return item.copyWith(status: DownloadStatus.queued, progress: 0);
}
return item;
}).toList();
// Only restore queued/downloading items (not completed/failed/skipped)
final pendingItems = restoredItems.where((item) =>
item.status == DownloadStatus.queued
).toList();
if (pendingItems.isNotEmpty) {
state = state.copyWith(items: pendingItems);
_log.i('Restored ${pendingItems.length} pending items from storage');
// Auto-resume queue processing
Future.microtask(() => _processQueue());
} else {
_log.d('No pending items to restore');
// Clear storage since nothing to restore
await prefs.remove(_queueStorageKey);
}
} else {
_log.d('No queue found in storage');
}
} catch (e) {
_log.e('Failed to load queue from storage: $e');
}
}
/// Save current queue to storage (only pending items)
Future<void> _saveQueueToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Only persist queued and downloading items
final pendingItems = state.items.where((item) =>
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading
).toList();
if (pendingItems.isEmpty) {
// Clear storage if no pending items
await prefs.remove(_queueStorageKey);
_log.d('Cleared queue storage (no pending items)');
} else {
final jsonList = pendingItems.map((e) => e.toJson()).toList();
await prefs.setString(_queueStorageKey, jsonEncode(jsonList));
_log.d('Saved ${pendingItems.length} pending items to storage');
}
} catch (e) {
_log.e('Failed to save queue to storage: $e');
}
}
void _startProgressPolling(String itemId) { void _startProgressPolling(String itemId) {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
@@ -461,6 +533,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
state = state.copyWith(items: [...state.items, item]); state = state.copyWith(items: [...state.items, item]);
_saveQueueToStorage(); // Persist queue
if (!state.isProcessing) { if (!state.isProcessing) {
// Run in microtask to not block UI // Run in microtask to not block UI
@@ -487,6 +560,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}).toList(); }).toList();
state = state.copyWith(items: [...state.items, ...newItems]); state = state.copyWith(items: [...state.items, ...newItems]);
_saveQueueToStorage(); // Persist queue
if (!state.isProcessing) { if (!state.isProcessing) {
// Run in microtask to not block UI // Run in microtask to not block UI
@@ -508,6 +582,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}).toList(); }).toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
// Persist queue when status changes to completed/failed/skipped (item removed from pending)
if (status == DownloadStatus.completed ||
status == DownloadStatus.failed ||
status == DownloadStatus.skipped) {
_saveQueueToStorage();
}
} }
void updateProgress(String id, double progress) { void updateProgress(String id, double progress) {
@@ -526,10 +607,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
).toList(); ).toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
} }
void clearAll() { void clearAll() {
state = state.copyWith(items: [], isPaused: false); state = state.copyWith(items: [], isPaused: false);
_saveQueueToStorage(); // Clear persisted queue
} }
/// Pause the download queue /// Pause the download queue
@@ -571,6 +654,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return item; return item;
}).toList(); }).toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
// Start processing if not already // Start processing if not already
if (!state.isProcessing) { if (!state.isProcessing) {
@@ -582,6 +666,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void removeItem(String id) { void removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList(); final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
} }
/// Embed metadata and cover to a FLAC file after M4A conversion /// Embed metadata and cover to a FLAC file after M4A conversion
+3 -5
View File
@@ -938,11 +938,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return; return;
} }
await SharePlus.instance.share( await Share.shareXFiles(
ShareParams( [XFile(item.filePath)],
files: [XFile(item.filePath)], text: '${item.trackName} - ${item.artistName}',
text: '${item.trackName} - ${item.artistName}',
),
); );
} }
+4 -4
View File
@@ -876,18 +876,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: share_plus name: share_plus
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.0.1" version: "10.1.4"
share_plus_platform_interface: share_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: share_plus_platform_interface name: share_plus_platform_interface
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "5.0.2"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
+2 -2
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: 1.6.0+25 version: 1.6.1+26
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -46,7 +46,7 @@ dependencies:
# Utils # Utils
url_launcher: ^6.3.1 url_launcher: ^6.3.1
device_info_plus: ^12.3.0 device_info_plus: ^12.3.0
share_plus: ^12.0.1 share_plus: ^10.1.4
receive_sharing_intent: ^1.8.1 receive_sharing_intent: ^1.8.1
logger: ^2.5.0 logger: ^2.5.0