diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9695371b..e70ad0ce 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -426,8 +426,14 @@ class DownloadQueueNotifier extends Notifier { Future.microtask(() async { try { updateSettings(ref.read(settingsProvider)); - await _initOutputDir(); + try { + await _initOutputDir(); + } catch (error, stack) { + _log.e('Failed to initialize download folder: $error', error, stack); + } await _loadQueueFromStorage(); + } catch (error, stack) { + _log.e('Failed to initialize download queue: $error', error, stack); } finally { if (!_queueRestored.isCompleted) _queueRestored.complete(); } @@ -1498,6 +1504,29 @@ class DownloadQueueNotifier extends Notifier { try { if (state.isProcessing) return; await _processQueueSingleFlight(); + } catch (error, stack) { + _log.e('Download queue processing failed: $error', error, stack); + _stopProgressPolling(); + _metadataEmbedding.clearCoverCache(); + _stopConnectivityMonitoring(); + if (ref.mounted) { + state = state.copyWith(isProcessing: false, currentDownload: null); + } + // Setup and finalization can fail outside the per-item pipeline. The + // queue owner still releases the execution window before another run. + try { + if (Platform.isAndroid) { + await PlatformBridge.stopDownloadService(); + } else if (Platform.isIOS) { + await PlatformBridge.endBackgroundDownloadTask(); + } + } catch (cleanupError, cleanupStack) { + _log.e( + 'Failed to release download execution window: $cleanupError', + cleanupError, + cleanupStack, + ); + } } finally { if (_queueProcessingGate.leave()) { Future.microtask(_processQueue); diff --git a/test/download_queue_recovery_test.dart b/test/download_queue_recovery_test.dart new file mode 100644 index 00000000..5bbcca6b --- /dev/null +++ b/test/download_queue_recovery_test.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + +class _Queue extends DownloadQueueNotifier { + @override + DownloadQueueState build() => const DownloadQueueState().copyWith( + items: [ + DownloadItem( + id: 'queued', + track: const Track( + id: 'example:track', + name: 'Song', + artistName: 'Artist', + albumName: 'Album', + duration: 180, + ), + service: 'example', + createdAt: DateTime.utc(2026), + ), + ], + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const paths = MethodChannel('plugins.flutter.io/path_provider'); + const connectivity = MethodChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); + + tearDown(() { + messenger.setMockMethodCallHandler(paths, null); + messenger.setMockMethodCallHandler(connectivity, null); + }); + + test('a failed queue setup releases processing and permits retry', () async { + var pathAttempts = 0; + messenger.setMockMethodCallHandler(paths, (_) async { + pathAttempts++; + throw PlatformException(code: 'folder_unavailable'); + }); + messenger.setMockMethodCallHandler(connectivity, (_) async => null); + final queue = _Queue(); + final container = ProviderContainer( + overrides: [ + downloadQueueProvider.overrideWith(() => queue), + settingsProvider.overrideWith(_Settings.new), + ], + ); + addTearDown(container.dispose); + var started = false; + var finished = Completer(); + container.listen(downloadQueueProvider, (previous, next) { + if (next.isProcessing) started = true; + if (started && !next.isProcessing && !finished.isCompleted) { + finished.complete(); + } + }); + + queue.resumePendingDownloadsOnForeground(); + await finished.future; + final state = container.read(downloadQueueProvider); + expect(state.isProcessing, isFalse); + expect(state.currentDownload, isNull); + expect(state.items.single.status, DownloadStatus.queued); + expect(pathAttempts, greaterThan(0)); + + final firstAttempts = pathAttempts; + started = false; + finished = Completer(); + queue.resumePendingDownloadsOnForeground(); + await finished.future; + expect(pathAttempts, greaterThan(firstAttempts)); + expect(container.read(downloadQueueProvider).isProcessing, isFalse); + }); +}