diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 31c707cf..9987354e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + () // Bumped every time a new native queue replaces the current one. A worker // coroutine that observes a different generation than its own must stop // without touching the snapshot or the service lifecycle: cancel() alone @@ -350,26 +362,7 @@ class DownloadService : Service() { } ACTION_PAUSE_NATIVE_QUEUE -> { nativeWorkerPaused = true - var itemIdToCancel = "" - synchronized(nativeWorkerItems) { - val activeItem = nativeWorkerItems.firstOrNull { - it.status == "downloading" || it.status == "finalizing" - } ?: nativeWorkerItems.firstOrNull { - it.itemId == nativeWorkerCurrentItemId && it.status == "queued" - } - activeItem?.let { - it.status = "queued" - itemIdToCancel = it.itemId - } - } - if (itemIdToCancel.isBlank()) itemIdToCancel = nativeWorkerCurrentItemId - if (itemIdToCancel.isNotBlank()) { - try { - Gobackend.cancelDownload(itemIdToCancel) - } catch (_: Exception) { - } - } - NativeDownloadFinalizer.cancelActiveWork() + cancelActiveNativeItemForPause() writeNativeWorkerSnapshotAsync( isRunning = nativeWorkerJob?.isActive == true, isPaused = true, @@ -380,16 +373,19 @@ class DownloadService : Service() { } ACTION_RESUME_NATIVE_QUEUE -> { nativeWorkerPaused = false + val stillPaused = isNativeWorkerPaused() writeNativeWorkerSnapshotAsync( isRunning = nativeWorkerJob?.isActive == true, - isPaused = false, + isPaused = stillPaused, currentItemId = "", - message = "Resumed", + message = if (stillPaused) nativeWorkerPauseMessage() else "Resumed", includeItems = true ) } ACTION_CANCEL_NATIVE_QUEUE -> { nativeWorkerCancelRequested = true + nativeWorkerVerificationPaused = false + cancelNativeVerificationNotification() synchronized(nativeWorkerItems) { for (item in nativeWorkerItems) { if (item.status == "queued" || @@ -474,7 +470,7 @@ class DownloadService : Service() { private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( + val progressChannel = NotificationChannel( CHANNEL_ID, "Download Service", NotificationManager.IMPORTANCE_LOW @@ -482,8 +478,17 @@ class DownloadService : Service() { description = "Shows download progress" setShowBadge(false) } + val alertChannel = NotificationChannel( + ALERT_CHANNEL_ID, + "Download Alerts", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Important download status and actions that need attention" + enableVibration(true) + } val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) + manager.createNotificationChannel(progressChannel) + manager.createNotificationChannel(alertChannel) } } @@ -506,11 +511,12 @@ class DownloadService : Service() { private fun startNativeWorker(requestsJson: String, settingsJson: String) { flushNativeAlbumReplayGainJournalIfComplete() - nativeWorkerRunId = parseNativeWorkerRunId(settingsJson) + val requestedRunId = parseNativeWorkerRunId(settingsJson) val requests = try { parseNativeDownloadRequests(requestsJson) } catch (e: Exception) { if (nativeWorkerJob?.isActive != true) { + nativeWorkerRunId = requestedRunId writeNativeWorkerSnapshot( isRunning = false, isPaused = false, @@ -523,6 +529,8 @@ class DownloadService : Service() { } return } + nativeWorkerRunId = requestedRunId + cancelNativeVerificationNotification() // Abort the previous run's in-flight work before the shared flags are // reset for the new run: the coroutine cancel below cannot interrupt a // blocking gomobile download by itself. @@ -544,7 +552,10 @@ class DownloadService : Service() { val generation = nativeWorkerGeneration nativeWorkerJob?.cancel(CancellationException("Native queue replaced")) nativeWorkerPaused = false + nativeWorkerNetworkPaused = false + nativeWorkerVerificationPaused = false nativeWorkerCancelRequested = false + unregisterNativeWorkerNetworkCallback() queueCount = requests.size synchronized(nativeReplayGainEntries) { nativeReplayGainEntries.clear() @@ -577,6 +588,7 @@ class DownloadService : Service() { } ) } + configureNativeWorkerNetworkPolicy(settingsJson) writeNativeReplayGainJournal() currentStatus = "preparing" currentTrackName = requests.firstOrNull()?.trackName ?: "" @@ -586,9 +598,9 @@ class DownloadService : Service() { startForegroundService() writeNativeWorkerSnapshot( isRunning = true, - isPaused = false, + isPaused = isNativeWorkerPaused(), currentItemId = "", - message = "Starting", + message = if (isNativeWorkerPaused()) nativeWorkerPauseMessage() else "Starting", settingsJson = settingsJson, includeItems = true ) @@ -606,6 +618,183 @@ class DownloadService : Service() { } } + private fun isNativeWorkerPaused(): Boolean = + nativeWorkerPaused || + nativeWorkerNetworkPaused || + nativeWorkerVerificationPaused + + private fun nativeWorkerPauseMessage(): String = when { + nativeWorkerVerificationPaused -> "Verification required" + nativeWorkerNetworkPaused -> "Waiting for Wi-Fi" + else -> "Paused" + } + + private fun cancelActiveNativeItemForPause() { + var itemIdToCancel = "" + synchronized(nativeWorkerItems) { + val activeItem = nativeWorkerItems.firstOrNull { + it.status == "downloading" || it.status == "finalizing" + } ?: nativeWorkerItems.firstOrNull { + it.itemId == nativeWorkerCurrentItemId && it.status == "queued" + } + activeItem?.let { + it.status = "queued" + it.progress = 0.0 + it.bytesReceived = 0L + it.bytesTotal = 0L + itemIdToCancel = it.itemId + } + } + if (itemIdToCancel.isBlank()) itemIdToCancel = nativeWorkerCurrentItemId + if (itemIdToCancel.isNotBlank()) { + try { + Gobackend.cancelDownload(itemIdToCancel) + } catch (_: Exception) { + } + } + NativeDownloadFinalizer.cancelActiveWork() + } + + private fun configureNativeWorkerNetworkPolicy(settingsJson: String) { + nativeWorkerDownloadNetworkMode = try { + JSONObject(settingsJson).optString("download_network_mode", "any") + } catch (_: Exception) { + "any" + } + + unregisterNativeWorkerNetworkCallback() + if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) { + nativeWorkerNetworkPaused = false + return + } + + nativeWorkerNetworkPaused = !hasUsableWifiConnection() + val connectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + synchronized(nativeWorkerWifiNetworks) { + nativeWorkerWifiNetworks.add(network) + } + refreshNativeWorkerNetworkPause() + } + + override fun onLost(network: Network) { + synchronized(nativeWorkerWifiNetworks) { + nativeWorkerWifiNetworks.remove(network) + } + refreshNativeWorkerNetworkPause() + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + synchronized(nativeWorkerWifiNetworks) { + if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET, + ) + ) { + nativeWorkerWifiNetworks.add(network) + } else { + nativeWorkerWifiNetworks.remove(network) + } + } + refreshNativeWorkerNetworkPause() + } + } + try { + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + nativeWorkerNetworkCallback = callback + } catch (e: Exception) { + android.util.Log.w( + "DownloadService", + "Failed to monitor Wi-Fi for native worker: ${e.message}", + ) + } + } + + private fun hasUsableWifiConnection(): Boolean { + val connectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (synchronized(nativeWorkerWifiNetworks) { + nativeWorkerWifiNetworks.isNotEmpty() + } + ) { + return true + } + return try { + val activeNetwork = connectivityManager.activeNetwork ?: return false + val capabilities = + connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } catch (_: Exception) { + false + } + } + + private fun refreshNativeWorkerNetworkPause() { + if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) return + + val shouldPause = NativeWorkerPolicy.shouldPauseForNetwork( + nativeWorkerDownloadNetworkMode, + hasUsableWifiConnection(), + ) + if (shouldPause == nativeWorkerNetworkPaused) return + + nativeWorkerNetworkPaused = shouldPause + if (nativeWorkerJob?.isActive != true) return + + if (shouldPause) { + currentStatus = if (nativeWorkerVerificationPaused) { + "verification_required" + } else { + "waiting_wifi" + } + cancelActiveNativeItemForPause() + updateNotification(0L, 0L) + } else { + currentStatus = if (nativeWorkerVerificationPaused) { + "verification_required" + } else { + "preparing" + } + updateNotification(0L, 0L) + } + writeNativeWorkerSnapshotAsync( + isRunning = nativeWorkerJob?.isActive == true, + isPaused = isNativeWorkerPaused(), + currentItemId = "", + message = if (isNativeWorkerPaused()) { + nativeWorkerPauseMessage() + } else { + "Wi-Fi restored" + }, + includeItems = true, + ) + } + + private fun unregisterNativeWorkerNetworkCallback() { + val callback = nativeWorkerNetworkCallback + nativeWorkerNetworkCallback = null + synchronized(nativeWorkerWifiNetworks) { + nativeWorkerWifiNetworks.clear() + } + if (callback == null) return + try { + val connectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + connectivityManager.unregisterNetworkCallback(callback) + } catch (_: Exception) { + } + } + private fun parseNativeDownloadRequests(requestsJson: String): List { val array = JSONArray(requestsJson) val requests = ArrayList(array.length()) @@ -665,13 +854,12 @@ class DownloadService : Service() { settingsJson: String, generation: Long ) { - var completed = 0 - var failed = 0 + val rateLimitAttempts = mutableMapOf() try { var requestIndex = 0 while (requestIndex < requests.size) { val request = requests[requestIndex] - while (nativeWorkerPaused && + while (isNativeWorkerPaused() && !nativeWorkerCancelRequested && generation == nativeWorkerGeneration ) { @@ -679,7 +867,7 @@ class DownloadService : Service() { isRunning = true, isPaused = true, currentItemId = request.itemId, - message = "Paused", + message = nativeWorkerPauseMessage(), settingsJson = settingsJson, includeItems = true ) @@ -778,9 +966,8 @@ class DownloadService : Service() { settingsJson ) { nativeWorkerCancelRequested || - nativeWorkerPaused || - generation != nativeWorkerGeneration || - nativeWorkerJob?.isActive == false + isNativeWorkerPaused() || + generation != nativeWorkerGeneration } } if (result.optBoolean("success", false)) { @@ -789,7 +976,6 @@ class DownloadService : Service() { nativeReplayGainEntries.add(JSONObject(replayGain.toString())) } } - completed++ updateNativeWorkerItem(request.itemId) { it.status = "completed" it.progress = 1.0 @@ -800,8 +986,9 @@ class DownloadService : Service() { writeNativeAlbumReplayGainIfComplete() } else { val errorType = result.optString("error_type") + val errorMessage = result.optString("error") if (errorType == "cancelled" && - !nativeWorkerPaused && + !isNativeWorkerPaused() && !nativeWorkerCancelRequested && generation == nativeWorkerGeneration ) { @@ -813,7 +1000,7 @@ class DownloadService : Service() { // cancellation as a permanent skip. var waitedMs = 0L while (waitedMs < 1500 && - !nativeWorkerPaused && + !isNativeWorkerPaused() && !nativeWorkerCancelRequested && generation == nativeWorkerGeneration ) { @@ -821,7 +1008,10 @@ class DownloadService : Service() { waitedMs += 100 } } - if (errorType == "cancelled" && nativeWorkerPaused && !nativeWorkerCancelRequested) { + if (errorType == "cancelled" && + isNativeWorkerPaused() && + !nativeWorkerCancelRequested + ) { updateNativeWorkerItem(request.itemId) { it.status = "queued" it.progress = 0.0 @@ -839,15 +1029,73 @@ class DownloadService : Service() { includeItems = true ) retryCurrentRequest = true + } else if (NativeWorkerPolicy.shouldRetryRateLimit( + errorType = errorType, + errorMessage = errorMessage, + attempts = rateLimitAttempts[request.itemId] ?: 0, + ) + ) { + rateLimitAttempts[request.itemId] = + (rateLimitAttempts[request.itemId] ?: 0) + 1 + val delaySeconds = NativeWorkerPolicy.rateLimitDelaySeconds( + retryAfterSeconds = result + .optInt("retry_after_seconds", 0) + .takeIf { it > 0 }, + errorMessage = errorMessage, + ) + currentStatus = "rate_limited" + updateNativeWorkerItem(request.itemId) { + it.status = "queued" + it.progress = 0.0 + it.bytesReceived = 0L + it.bytesTotal = 0L + it.error = "Rate limited, retrying in ${delaySeconds}s" + it.resultJson = null + } + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = isNativeWorkerPaused(), + currentItemId = request.itemId, + message = "Rate limited, retrying in ${delaySeconds}s", + settingsJson = settingsJson, + includeItems = true, + ) + updateNotification(0L, 0L) + delay(delaySeconds * 1000L) + retryCurrentRequest = true + } else if (NativeWorkerPolicy.isVerificationRequired( + errorType = errorType, + errorMessage = errorMessage, + ) + ) { + nativeWorkerVerificationPaused = true + currentStatus = "verification_required" + updateNativeWorkerItem(request.itemId) { + it.status = "failed" + it.error = errorMessage + it.resultJson = result + } + writeNativeReplayGainJournal() + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = true, + currentItemId = request.itemId, + message = "Verification required", + lastResult = result, + settingsJson = settingsJson, + includeItems = true, + ) + scheduleNativeVerificationNotification(generation) + updateNotification(0L, 0L) + retryCurrentRequest = true } else { - failed++ updateNativeWorkerItem(request.itemId) { it.status = if (errorType == "cancelled") { "skipped" } else { "failed" } - it.error = result.optString("error") + it.error = errorMessage it.resultJson = result } writeNativeReplayGainJournal() @@ -873,7 +1121,6 @@ class DownloadService : Service() { } throw e } catch (e: Exception) { - failed++ updateNativeWorkerItem(request.itemId) { it.status = "failed" it.error = e.message ?: "Native download failed" @@ -907,6 +1154,13 @@ class DownloadService : Service() { if (!nativeWorkerCancelRequested) { flushNativeAlbumReplayGainJournalIfComplete() } + val counts = nativeWorkerCounts() + val shouldNotifyCompletion = + NativeWorkerPolicy.shouldNotifyQueueComplete( + cancelRequested = nativeWorkerCancelRequested, + completed = counts.completed, + failed = counts.failed, + ) currentStatus = "finalizing" writeNativeWorkerSnapshot( isRunning = false, @@ -917,6 +1171,9 @@ class DownloadService : Service() { includeItems = true ) stopForegroundService(cancelNativeWorker = false) + if (shouldNotifyCompletion) { + showNativeQueueComplete(counts) + } } } } @@ -1444,6 +1701,9 @@ class DownloadService : Service() { NativeDownloadFinalizer.cancelActiveWork() nativeWorkerJob?.cancel(CancellationException("Download service stopped")) nativeWorkerPaused = false + nativeWorkerNetworkPaused = false + nativeWorkerVerificationPaused = false + cancelNativeVerificationNotification() } if (cancelNativeWorker && hasNativeWorkerState()) { writeNativeWorkerSnapshot( @@ -1454,6 +1714,9 @@ class DownloadService : Service() { includeItems = true ) } + unregisterNativeWorkerNetworkCallback() + nativeWorkerDownloadNetworkMode = "any" + nativeWorkerNetworkPaused = false nativeWorkerJob = null isRunning = false releaseWakeLock() @@ -1493,7 +1756,13 @@ class DownloadService : Service() { "Downloading..." } - val text = if (currentStatus == "finalizing") { + val text = if (currentStatus == "verification_required") { + "Open the app to complete verification" + } else if (currentStatus == "rate_limited") { + "Rate limited, retrying shortly..." + } else if (currentStatus == "waiting_wifi") { + "Waiting for Wi-Fi..." + } else if (currentStatus == "finalizing") { if (currentArtistName.isNotEmpty()) currentArtistName else "Embedding metadata..." } else if (currentStatus == "preparing" && total <= 0) { "Preparing download..." @@ -1531,8 +1800,102 @@ class DownloadService : Service() { return builder.build() } + + private fun showNativeQueueComplete(counts: NativeWorkerCounts) { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val title = if (counts.failed > 0) { + "Downloads Finished (${counts.completed} done, ${counts.failed} failed)" + } else { + "All Downloads Complete" + } + val body = if (counts.failed > 0) { + "${counts.completed} downloaded, ${counts.failed} failed" + } else { + "${counts.completed} tracks downloaded successfully" + } + val builder = NotificationCompat.Builder(this, ALERT_CHANNEL_ID) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_STATUS) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + builder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE) + } + + try { + val manager = getSystemService(NotificationManager::class.java) + manager.notify(DOWNLOAD_RESULT_NOTIFICATION_ID, builder.build()) + } catch (e: SecurityException) { + android.util.Log.w( + "DownloadService", + "Completion notification permission denied: ${e.message}", + ) + } + } + + private fun scheduleNativeVerificationNotification(generation: Long) { + serviceScope.launch { + // Give an active Flutter poller time to take ownership of the + // verification flow. If Flutter is suspended, the service remains + // paused and publishes the alert itself. + delay(2_000L) + if (generation == nativeWorkerGeneration && + nativeWorkerVerificationPaused && + !nativeWorkerCancelRequested + ) { + showNativeVerificationRequired() + } + } + } + + private fun showNativeVerificationRequired() { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = NotificationCompat.Builder(this, ALERT_CHANNEL_ID) + .setContentTitle("Verification required") + .setContentText("Open the app to complete verification and resume downloads") + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + builder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE) + } + + try { + val manager = getSystemService(NotificationManager::class.java) + manager.notify(VERIFICATION_REQUIRED_NOTIFICATION_ID, builder.build()) + } catch (e: SecurityException) { + android.util.Log.w( + "DownloadService", + "Verification notification permission denied: ${e.message}", + ) + } + } + + private fun cancelNativeVerificationNotification() { + val manager = getSystemService(NotificationManager::class.java) + manager.cancel(VERIFICATION_REQUIRED_NOTIFICATION_ID) + } override fun onDestroy() { + unregisterNativeWorkerNetworkCallback() nativeWorkerCancelRequested = true NativeDownloadFinalizer.cancelActiveWork() nativeWorkerJob?.cancel(CancellationException("Download service destroyed")) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt new file mode 100644 index 00000000..2fb94932 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt @@ -0,0 +1,71 @@ +package com.zarz.spotiflac + +internal object NativeWorkerPolicy { + const val MAX_RATE_LIMIT_RETRIES = 1 + private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30 + private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5 + private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300 + + private val retryAfterPattern = Regex( + """retry[- ]?after(?: seconds)?[:= ]+(\d+)""", + RegexOption.IGNORE_CASE, + ) + + fun shouldRetryRateLimit( + errorType: String?, + errorMessage: String?, + attempts: Int, + ): Boolean { + if (attempts >= MAX_RATE_LIMIT_RETRIES) return false + if (errorType.equals("rate_limit", ignoreCase = true)) return true + + val message = errorMessage.orEmpty() + return message.contains("429") || + message.contains("rate limit", ignoreCase = true) || + message.contains("too many requests", ignoreCase = true) + } + + fun rateLimitDelaySeconds( + retryAfterSeconds: Int?, + errorMessage: String?, + ): Int { + val parsedFromMessage = retryAfterPattern + .find(errorMessage.orEmpty()) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + return (retryAfterSeconds?.takeIf { it > 0 } + ?: parsedFromMessage + ?: DEFAULT_RATE_LIMIT_DELAY_SECONDS) + .coerceIn( + MIN_RATE_LIMIT_DELAY_SECONDS, + MAX_RATE_LIMIT_DELAY_SECONDS, + ) + } + + fun isVerificationRequired( + errorType: String?, + errorMessage: String?, + ): Boolean { + if (errorType.equals("verification_required", ignoreCase = true)) { + return true + } + val message = errorMessage.orEmpty() + return message.contains("verification required", ignoreCase = true) || + message.contains("challenge required", ignoreCase = true) + } + + fun requiresWifi(downloadNetworkMode: String?): Boolean = + downloadNetworkMode.equals("wifi_only", ignoreCase = true) + + fun shouldPauseForNetwork( + downloadNetworkMode: String?, + hasWifi: Boolean, + ): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi + + fun shouldNotifyQueueComplete( + cancelRequested: Boolean, + completed: Int, + failed: Int, + ): Boolean = !cancelRequested && completed + failed > 0 +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 310cf8a7..47435e52 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -241,6 +241,8 @@ class DownloadQueueNotifier extends Notifier { final Map> _verificationFlowsByExtension = {}; final Set _rateLimitRetriedItemIds = {}; String? _activeNativeWorkerRunId; + bool get _hasActiveAndroidNativeWorker => + Platform.isAndroid && _activeNativeWorkerRunId?.isNotEmpty == true; // Album ReplayGain accumulator: keyed by album identifier. // Stores per-track loudness data until all album tracks are done, @@ -1564,8 +1566,7 @@ class DownloadQueueNotifier extends Notifier { } state = state.copyWith(items: [], isPaused: false, currentDownload: null); - if (Platform.isAndroid && - ref.read(settingsProvider).nativeDownloadWorkerEnabled) { + if (_hasActiveAndroidNativeWorker) { PlatformBridge.cancelNativeDownloadWorker().catchError((_) {}); } _notificationService.cancelDownloadNotification(); @@ -1579,8 +1580,7 @@ class DownloadQueueNotifier extends Notifier { void pauseQueue() { if (state.isProcessing && !state.isPaused) { - if (Platform.isAndroid && - ref.read(settingsProvider).nativeDownloadWorkerEnabled) { + if (_hasActiveAndroidNativeWorker) { PlatformBridge.pauseNativeDownloadWorker().catchError((_) {}); } final activeIds = state.items @@ -1608,8 +1608,7 @@ class DownloadQueueNotifier extends Notifier { void resumeQueue() { if (state.isPaused) { - if (Platform.isAndroid && - ref.read(settingsProvider).nativeDownloadWorkerEnabled) { + if (_hasActiveAndroidNativeWorker) { PlatformBridge.resumeNativeDownloadWorker().catchError((_) {}); } state = state.copyWith(isPaused: false); diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index bc7cd924..3c05b181 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -424,6 +424,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { await Future.delayed(const Duration(seconds: 1)); continue; } + state = state.copyWith(isPaused: snapshot['is_paused'] == true); await _rebuildPendingNativeWorkerContexts( contexts, pendingContextIds, @@ -522,6 +523,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { 'run_id': runId, 'created_at': DateTime.now().toIso8601String(), 'save_download_history': settings.saveDownloadHistory, + 'download_network_mode': settings.downloadNetworkMode, }, ); @@ -604,13 +606,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { } } - if (_totalQueuedAtStart > 0) { - await _notificationService.showQueueComplete( - completedCount: _completedInSession, - failedCount: _failedInSession, - ); - } - final hasQueuedItems = state.items.any( (item) => item.status == DownloadStatus.queued, ); @@ -975,13 +970,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { ); }, ); - await _notificationService.showDownloadComplete( - trackName: item.track.name, - artistName: item.track.artistName, - completedCount: _completedInSession, - totalCount: _totalQueuedAtStart, - alreadyInLibrary: result['already_exists'] == true, - ); removeItem(item.id); return; } @@ -1221,14 +1209,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { ); }, ); - await _notificationService.showDownloadComplete( - trackName: item.track.name, - artistName: item.track.artistName, - completedCount: _completedInSession, - totalCount: _totalQueuedAtStart, - alreadyInLibrary: result['already_exists'] == true, - ); - removeItem(item.id); }