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);
}