diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index 487722a4..a9477f50 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -374,6 +374,7 @@ class DownloadService : Service() { @Volatile private var nativeWorkerCancelRequested = false internal var nativeWorkerDownloadNetworkMode = "any" internal var nativeWorkerNetworkCallback: ConnectivityManager.NetworkCallback? = null + internal var nativeWorkerNetworkFallbackJob: Job? = null internal val nativeWorkerWifiNetworks = mutableSetOf() // Bumped every time a new native queue replaces the current one. A worker // coroutine that observes a different generation than its own must stop diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt index 34537331..887ba853 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt @@ -94,6 +94,17 @@ internal fun DownloadService.configureNativeWorkerNetworkPolicy(settingsJson: St "DownloadService", "Failed to monitor Wi-Fi for native worker: ${e.message}", ) + // Callback registration can fail when the process/system callback quota is + // exhausted. Keep the Wi-Fi-only queue observable instead of leaving it + // paused forever with no event capable of resuming it. + nativeWorkerNetworkFallbackJob?.cancel() + nativeWorkerNetworkFallbackJob = serviceScope.launch { + while (NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) { + delay(5_000) + refreshNativeWorkerNetworkPause() + if (nativeWorkerJob?.isActive != true) break + } + } } } @@ -159,6 +170,8 @@ internal fun DownloadService.refreshNativeWorkerNetworkPause() { } internal fun DownloadService.unregisterNativeWorkerNetworkCallback() { + nativeWorkerNetworkFallbackJob?.cancel() + nativeWorkerNetworkFallbackJob = null val callback = nativeWorkerNetworkCallback nativeWorkerNetworkCallback = null synchronized(nativeWorkerWifiNetworks) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt index ae04705d..3925e1b2 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt @@ -329,7 +329,7 @@ internal fun DownloadService.cancelNativeWorkerProgressCoordinator() { private fun DownloadService.pollNativeWorkerProgress(generation: Long): Set { val sinceSeq = synchronized(nativeWorkerProgressLock) { nativeWorkerProgressSeq } val raw = try { - Gobackend.getAllDownloadProgressDelta(sinceSeq) + Gobackend.waitForAllDownloadProgressDelta(sinceSeq, 5_000L) } catch (_: Exception) { return emptySet() } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index e62e4598..4b99634c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -66,7 +66,6 @@ class MainActivity: FlutterFragmentActivity() { "com.zarz.spotiflac/download_progress_stream" private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream" - private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L // A progress bar can't show sub-second granularity; 400ms halves the // disk-read wakeups during a scan vs the previous 200ms. private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 400L @@ -482,12 +481,17 @@ class MainActivity: FlutterFragmentActivity() { while (isActive && downloadProgressEventSink === sink) { try { val payload = withContext(Dispatchers.IO) { - Gobackend.getAllDownloadProgressDelta(lastDownloadProgressSeq) + Gobackend.waitForAllDownloadProgressDelta( + lastDownloadProgressSeq, + 15_000L, + ) } + if (!isActive || downloadProgressEventSink !== sink) break if (payload.isNotEmpty() && payload != lastDownloadProgressPayload) { updateDownloadProgressSeq(payload) lastDownloadProgressPayload = payload sink.success(parseJsonPayload(payload)) + delay(250L) } } catch (e: Exception) { android.util.Log.w( @@ -495,7 +499,7 @@ class MainActivity: FlutterFragmentActivity() { "Download progress stream poll failed: ${e.message}", ) } - delay(DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS) + if (downloadProgressEventSink !== sink) break } } } @@ -517,6 +521,7 @@ class MainActivity: FlutterFragmentActivity() { val initialPayload = withContext(Dispatchers.IO) { readLibraryScanProgressJsonForStream() } + if (!isActive || libraryScanProgressEventSink !== sink) return@launch lastLibraryScanProgressPayload = initialPayload sink.success(parseJsonPayload(initialPayload)) } catch (e: Exception) { @@ -530,6 +535,7 @@ class MainActivity: FlutterFragmentActivity() { val payload = withContext(Dispatchers.IO) { readLibraryScanProgressJsonForStream() } + if (!isActive || libraryScanProgressEventSink !== sink) break if (payload != lastLibraryScanProgressPayload) { lastLibraryScanProgressPayload = payload sink.success(parseJsonPayload(payload)) diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index 34068161..ae58dec2 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -433,6 +433,10 @@ func GetAllDownloadProgressDelta(sinceSeq int64) string { return GetMultiProgressDelta(sinceSeq) } +func WaitForAllDownloadProgressDelta(sinceSeq, timeoutMs int64) string { + return WaitForMultiProgressDelta(sinceSeq, timeoutMs) +} + func InitItemProgress(itemID string) { StartItemProgress(itemID) } diff --git a/go_backend/progress.go b/go_backend/progress.go index 33b5a4b6..6539526c 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -67,6 +67,7 @@ var ( multiProgressSeq int64 multiProgressReset int64 removedProgressSeq = make(map[string]int64) + multiProgressNotify = make(chan struct{}) ) func markMultiProgressDirtyLocked() { @@ -75,6 +76,8 @@ func markMultiProgressDirtyLocked() { func nextMultiProgressSeqLocked() int64 { multiProgressSeq++ + close(multiProgressNotify) + multiProgressNotify = make(chan struct{}) return multiProgressSeq } @@ -180,6 +183,37 @@ func GetMultiProgressDelta(sinceSeq int64) string { return string(jsonBytes) } +// WaitForMultiProgressDelta blocks without polling until the bridge revision +// advances or the bounded heartbeat expires. Each waiter observes the same +// close-only notification channel, so UI and native-worker consumers do not +// compete for events. +func WaitForMultiProgressDelta(sinceSeq, timeoutMs int64) string { + if timeoutMs <= 0 { + timeoutMs = 15_000 + } + if timeoutMs > 60_000 { + timeoutMs = 60_000 + } + timer := time.NewTimer(time.Duration(timeoutMs) * time.Millisecond) + defer timer.Stop() + for { + multiMu.RLock() + if sinceSeq < multiProgressSeq { + multiMu.RUnlock() + return GetMultiProgressDelta(sinceSeq) + } + notify := multiProgressNotify + multiMu.RUnlock() + + select { + case <-notify: + continue + case <-timer.C: + return "" + } + } +} + func StartItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() diff --git a/go_backend/progress_test.go b/go_backend/progress_test.go index 3fd64c29..6c19a9dc 100644 --- a/go_backend/progress_test.go +++ b/go_backend/progress_test.go @@ -6,6 +6,51 @@ import ( "time" ) +func TestWaitForMultiProgressDeltaWakesOnRevision(t *testing.T) { + ClearAllItemProgress() + defer ClearAllItemProgress() + + multiMu.RLock() + since := multiProgressSeq + multiMu.RUnlock() + result := make(chan string, 1) + go func() { + result <- WaitForMultiProgressDelta(since, 1_000) + }() + time.Sleep(10 * time.Millisecond) + StartItemProgress("wait-progress") + select { + case payload := <-result: + if payload == "" { + t.Fatal("waiter woke without a progress delta") + } + var delta MultiProgressDelta + if err := json.Unmarshal([]byte(payload), &delta); err != nil { + t.Fatalf("decode delta: %v", err) + } + if delta.Items["wait-progress"] == nil { + t.Fatalf("delta missing item: %#v", delta) + } + case <-time.After(time.Second): + t.Fatal("progress waiter did not wake") + } +} + +func TestWaitForMultiProgressDeltaHeartbeatTimeout(t *testing.T) { + ClearAllItemProgress() + defer ClearAllItemProgress() + multiMu.RLock() + since := multiProgressSeq + multiMu.RUnlock() + startedAt := time.Now() + if payload := WaitForMultiProgressDelta(since, 20); payload != "" { + t.Fatalf("timeout payload = %q, want empty", payload) + } + if elapsed := time.Since(startedAt); elapsed < 15*time.Millisecond { + t.Fatalf("wait returned too early after %v", elapsed) + } +} + func TestItemTransferProgressReporterCoalescesHotPathUpdates(t *testing.T) { ClearAllItemProgress() defer ClearAllItemProgress() diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c1cf73f1..acbc960a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -12,13 +12,19 @@ import Gobackend private let LARGE_JSON_RESULT_FILE_KEY = "__json_file" private let LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024 private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility) + private let downloadProgressQueue = DispatchQueue( + label: "com.zarz.spotiflac.download_progress_stream", + qos: .utility + ) private var downloadProgressTimer: DispatchSourceTimer? private var downloadProgressEventSink: FlutterEventSink? private var lastDownloadProgressPayload: String? private var lastDownloadProgressSeq: Int64 = 0 + private var downloadProgressGeneration: UInt64 = 0 private var libraryScanProgressTimer: DispatchSourceTimer? private var libraryScanProgressEventSink: FlutterEventSink? private var lastLibraryScanProgressPayload: String? + private var libraryScanProgressGeneration: UInt64 = 0 private var backendChannel: FlutterMethodChannel? private var pendingSessionGrantEvents: [[String: Any]] = [] @@ -222,22 +228,28 @@ import Gobackend private func startDownloadProgressStream(_ eventSink: @escaping FlutterEventSink) { stopDownloadProgressStream() + downloadProgressGeneration &+= 1 + let generation = downloadProgressGeneration downloadProgressEventSink = eventSink lastDownloadProgressPayload = nil lastDownloadProgressSeq = 0 - let timer = DispatchSource.makeTimerSource(queue: streamQueue) - timer.schedule(deadline: .now(), repeating: .milliseconds(800)) + let timer = DispatchSource.makeTimerSource(queue: downloadProgressQueue) + timer.schedule(deadline: .now(), repeating: .milliseconds(250)) timer.setEventHandler { [weak self] in - guard let self else { return } - let payload = GobackendGetAllDownloadProgressDelta(self.lastDownloadProgressSeq) as String? ?? "" + guard let self, self.downloadProgressGeneration == generation else { return } + let payload = GobackendWaitForAllDownloadProgressDelta( + self.lastDownloadProgressSeq, + 15_000 + ) as String? ?? "" if payload.isEmpty || payload == self.lastDownloadProgressPayload { return } self.updateDownloadProgressSeq(payload) self.lastDownloadProgressPayload = payload DispatchQueue.main.async { [weak self] in - self?.downloadProgressEventSink?(self?.parseJsonPayload(payload)) + guard let self, self.downloadProgressGeneration == generation else { return } + eventSink(self.parseJsonPayload(payload)) } } downloadProgressTimer = timer @@ -245,6 +257,7 @@ import Gobackend } private func stopDownloadProgressStream() { + downloadProgressGeneration &+= 1 downloadProgressTimer?.setEventHandler {} downloadProgressTimer?.cancel() downloadProgressTimer = nil @@ -255,20 +268,23 @@ import Gobackend private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) { stopLibraryScanProgressStream() + libraryScanProgressGeneration &+= 1 + let generation = libraryScanProgressGeneration libraryScanProgressEventSink = eventSink lastLibraryScanProgressPayload = nil let timer = DispatchSource.makeTimerSource(queue: streamQueue) timer.schedule(deadline: .now(), repeating: .milliseconds(800)) timer.setEventHandler { [weak self] in - guard let self else { return } + guard let self, self.libraryScanProgressGeneration == generation else { return } let payload = GobackendGetLibraryScanProgressJSON() as String? ?? "{}" if payload == self.lastLibraryScanProgressPayload { return } self.lastLibraryScanProgressPayload = payload DispatchQueue.main.async { [weak self] in - self?.libraryScanProgressEventSink?(self?.parseJsonPayload(payload)) + guard let self, self.libraryScanProgressGeneration == generation else { return } + eventSink(self.parseJsonPayload(payload)) } } libraryScanProgressTimer = timer @@ -276,6 +292,7 @@ import Gobackend } private func stopLibraryScanProgressStream() { + libraryScanProgressGeneration &+= 1 libraryScanProgressTimer?.setEventHandler {} libraryScanProgressTimer?.cancel() libraryScanProgressTimer = nil diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index f3fcaae6..dc4d7180 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -3,6 +3,8 @@ import 'package:spotiflac_android/models/track.dart'; part 'download_item.g.dart'; +const Object _downloadItemUnset = Object(); + enum DownloadStatus { queued, downloading, @@ -72,9 +74,9 @@ class DownloadItem { double? speedMBps, int? bytesReceived, int? bytesTotal, - String? filePath, - String? error, - DownloadErrorType? errorType, + Object? filePath = _downloadItemUnset, + Object? error = _downloadItemUnset, + Object? errorType = _downloadItemUnset, String? preparationStage, DateTime? createdAt, String? qualityOverride, @@ -92,9 +94,15 @@ class DownloadItem { speedMBps: speedMBps ?? this.speedMBps, bytesReceived: bytesReceived ?? this.bytesReceived, bytesTotal: bytesTotal ?? this.bytesTotal, - filePath: filePath ?? this.filePath, - error: error ?? this.error, - errorType: errorType ?? this.errorType, + filePath: identical(filePath, _downloadItemUnset) + ? this.filePath + : filePath as String?, + error: identical(error, _downloadItemUnset) + ? this.error + : error as String?, + errorType: identical(errorType, _downloadItemUnset) + ? this.errorType + : errorType as DownloadErrorType?, preparationStage: preparationStage ?? this.preparationStage, createdAt: createdAt ?? this.createdAt, qualityOverride: qualityOverride ?? this.qualityOverride, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index f7266a23..1f1f9660 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1311,7 +1311,7 @@ class DownloadQueueNotifier extends Notifier { } } - void retryItem(String id) { + Future retryItem(String id) async { final item = state.items.where((i) => i.id == id).firstOrNull; if (item == null) { _log.w('retryItem: Item not found: $id'); @@ -1328,11 +1328,18 @@ class DownloadQueueNotifier extends Notifier { // A cancel issued while the item never started leaves a pre-registered // flag in the Go backend that the next attempt would consume and abort // instantly; the user asked for a retry, so drop it first. - unawaited( - PlatformBridge.resetDownloadCancel(id).catchError((Object e) { - _log.w('Failed to reset cancel flag for $id: $e'); - }), - ); + try { + await PlatformBridge.resetDownloadCancel(id); + } catch (e) { + _log.w('Failed to reset cancel flag for $id: $e'); + return; + } + final current = state.items.where((i) => i.id == id).firstOrNull; + if (current == null || + (current.status != DownloadStatus.failed && + current.status != DownloadStatus.skipped)) { + return; + } _locallyCancelledItemIds.remove(id); _verificationRetryGuard.clearItem(id); _rateLimitRetriedItemIds.remove(id); @@ -1347,6 +1354,8 @@ class DownloadQueueNotifier extends Notifier { status: DownloadStatus.queued, progress: 0, error: null, + errorType: null, + filePath: null, ); } return i; @@ -1362,7 +1371,7 @@ class DownloadQueueNotifier extends Notifier { } } - void retryAllFailed({bool networkOnly = false}) { + Future retryAllFailed({bool networkOnly = false}) async { final failedIds = state.items .where( (item) => @@ -1378,24 +1387,34 @@ class DownloadQueueNotifier extends Notifier { } _log.i('Retrying ${failedIds.length} failed download(s)'); - for (final id in failedIds) { - unawaited( - PlatformBridge.resetDownloadCancel(id).catchError((Object e) { + final resetResults = await Future.wait( + failedIds.map((id) async { + try { + await PlatformBridge.resetDownloadCancel(id); + return id; + } catch (e) { _log.w('Failed to reset cancel flag for $id: $e'); - }), - ); - } - _locallyCancelledItemIds.removeAll(failedIds); - _pausePendingItemIds.removeAll(failedIds); + return null; + } + }), + ); + final retryableIds = resetResults.whereType().toSet(); + if (retryableIds.isEmpty) return; + _locallyCancelledItemIds.removeAll(retryableIds); + _pausePendingItemIds.removeAll(retryableIds); for (final item in state.items) { - if (!failedIds.contains(item.id)) continue; + if (!retryableIds.contains(item.id)) continue; _purgeAlbumRgEntry(item.track); } final items = state.items .map((item) { - if (!failedIds.contains(item.id)) return item; + if (!retryableIds.contains(item.id)) return item; + if (item.status != DownloadStatus.failed && + item.status != DownloadStatus.skipped) { + return item; + } return item.copyWith( status: DownloadStatus.queued, progress: 0, @@ -1403,6 +1422,8 @@ class DownloadQueueNotifier extends Notifier { bytesReceived: 0, bytesTotal: 0, error: null, + errorType: null, + filePath: null, ); }) .toList(growable: false); diff --git a/lib/utils/progress_stream_poller.dart b/lib/utils/progress_stream_poller.dart index 133ab781..dc02136b 100644 --- a/lib/utils/progress_stream_poller.dart +++ b/lib/utils/progress_stream_poller.dart @@ -56,8 +56,9 @@ class ProgressStreamPoller { StreamSubscription? _sub; bool _hasReceivedStreamEvent = false; bool _usingStream = false; - bool _inFlight = false; + int? _inFlightGeneration; int _errorCount = 0; + int _generation = 0; bool get usingStream => _usingStream; @@ -65,6 +66,8 @@ class ProgressStreamPoller { /// stream (falling back to polling on timeout/error); otherwise starts /// polling immediately. void start({required bool useStream}) { + _generation++; + final generation = _generation; _timer?.cancel(); _bootstrapTimer?.cancel(); _bootstrapTimer = null; @@ -74,22 +77,28 @@ class ProgressStreamPoller { _usingStream = false; if (useStream) { - _attachStream(); + _attachStream(generation); return; } - startPollingTimer(); + startPollingTimer(generation: generation); } - void _attachStream() { + void _attachStream(int generation) { _sub = streamProvider().listen( (progress) async { + if (generation != _generation) return; _hasReceivedStreamEvent = true; _usingStream = true; _bootstrapTimer?.cancel(); _bootstrapTimer = null; - await _runGuarded(() async => progress, onStreamProcessingError); + await _runGuarded( + () async => progress, + onStreamProcessingError, + generation: generation, + ); }, onError: (Object error, StackTrace stackTrace) { + if (generation != _generation) return; if (_usingStream) { onStreamFailed(error); } @@ -98,59 +107,74 @@ class ProgressStreamPoller { _usingStream = false; _bootstrapTimer?.cancel(); _bootstrapTimer = null; - startPollingTimer(); + startPollingTimer(generation: generation); }, cancelOnError: false, ); _bootstrapTimer = Timer(bootstrapTimeout, () { + if (generation != _generation) return; if (_hasReceivedStreamEvent) return; onStreamTimeout(); _sub?.cancel(); _sub = null; _usingStream = false; - startPollingTimer(); + startPollingTimer(generation: generation); }); } /// (Re)starts the fallback polling timer. - void startPollingTimer() { + void startPollingTimer({int? generation}) { + final activeGeneration = generation ?? _generation; + if (activeGeneration != _generation) return; _timer?.cancel(); _timer = Timer.periodic(pollingInterval, (_) async { - await _runGuarded(pollProvider, onPollError, gate: shouldPollTick); + await _runGuarded( + pollProvider, + onPollError, + gate: shouldPollTick, + generation: activeGeneration, + ); }); } /// One-shot immediate fetch reusing the same in-flight guard and error /// counter as the periodic poll, with its own error callback. Future pollOnce(void Function(Object error) onError) => - _runGuarded(pollProvider, onError); + _runGuarded(pollProvider, onError, generation: _generation); Future _runGuarded( Future Function() fetch, void Function(Object error) onError, { bool Function()? gate, + required int generation, }) async { - if (_inFlight) return; - _inFlight = true; + if (generation != _generation || _inFlightGeneration == generation) return; + _inFlightGeneration = generation; try { if (gate != null && !gate()) return; final progress = await fetch(); + if (generation != _generation) return; await onProgress(progress); + if (generation != _generation) return; _errorCount = 0; } catch (e) { + if (generation != _generation) return; _errorCount++; if (_errorCount <= _errorLogThreshold) { onError(e); } } finally { - _inFlight = false; + if (_inFlightGeneration == generation) { + _inFlightGeneration = null; + } } } /// Cancels timers/subscription and resets guard/counter state, without /// releasing the poller for reuse (matches each caller's `_stop*` reset). void stop() { + _generation++; _timer?.cancel(); _bootstrapTimer?.cancel(); _sub?.cancel(); @@ -158,7 +182,7 @@ class ProgressStreamPoller { _bootstrapTimer = null; _sub = null; _errorCount = 0; - _inFlight = false; + _inFlightGeneration = null; _hasReceivedStreamEvent = false; _usingStream = false; } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 5d196694..fb0807f3 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -281,10 +281,10 @@ void main() { ); }); - test('polls one progress delta stream and preserves concurrent items', () { + test('waits on one progress delta stream and preserves concurrent items', () { expect( RegExp( - r'Gobackend\.getAllDownloadProgressDelta\(', + r'Gobackend\.waitForAllDownloadProgressDelta\(', ).allMatches(workerSnapshotSource), hasLength(1), ); @@ -726,6 +726,7 @@ void main() { track: sampleTrack(), service: 'qobuz', createdAt: DateTime.utc(2026), + filePath: '/music/stale.flac', error: 'raw backend failure', ); @@ -746,7 +747,16 @@ void main() { base.copyWith(errorType: DownloadErrorType.permission).errorMessage, 'Cannot write to folder, check storage permission', ); - expect(base.copyWith(error: null).errorMessage, 'raw backend failure'); + expect(base.copyWith(error: null).errorMessage, isEmpty); + expect(base.copyWith().filePath, '/music/stale.flac'); + expect(base.copyWith(filePath: null).filePath, isNull); + expect( + base + .copyWith(errorType: DownloadErrorType.network) + .copyWith(errorType: null) + .errorType, + isNull, + ); }); test('decodes json defaults and enums', () {