fix(background): keep library scans and update downloads running with the screen off

Android froze the process once the activity stopped, because only track downloads held a foreground service. A dataSync BackgroundWorkService with a partial wake lock now covers library scans and in-app APK downloads, and stops when the last task ends or the system timeout fires. Its notification shows the existing localized progress, so no duplicate appears. If Android refuses the service, work continues as before.

On iOS a scan holds its own background task; on expiry it cancels the scan cleanly instead of being suspended.
This commit is contained in:
zarzet committed 2026-10-01 18:46:21 +07:00
1 parent ad9670eb46
commit 6e6d9dd00e
8 files changed
+455 -25

No files matched your search

+6
View File
@@ -153,6 +153,12 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Library scans and app update downloads with the screen off -->
<service
android:name=".BackgroundWorkService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".DownloadQueueWidgetProvider"
android:exported="false">
@@ -0,0 +1,218 @@
package com.zarz.spotiflac
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import androidx.core.app.NotificationCompat
/**
* Keeps user-started, non-download work (library scans, app update
* downloads) running with the screen off. Without a foreground service the
* process becomes cached when the activity stops, and Android freezes it.
*
* Work is reference-counted by kind; the service stops itself when the last
* kind ends. Its notification shows the most recently updated work, using the
* localized title and text supplied by Dart.
*/
class BackgroundWorkService : Service() {
private data class Work(val title: String, val text: String, val progress: Int)
companion object {
private const val CHANNEL_ID = "background_work"
private const val NOTIFICATION_ID = 1002
private const val WAKELOCK_TAG = "SpotiFLAC:BackgroundWorkWakeLock"
private const val WAKELOCK_TIMEOUT_MS = 30 * 60 * 1000L
private const val WAKELOCK_RENEW_INTERVAL_MS = 15 * 60 * 1000L
private val works = LinkedHashMap<String, Work>()
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile
private var instance: BackgroundWorkService? = null
/**
* Returns false when Android refuses a foreground service (for example
* when started from the background); the work then runs as before.
*/
fun start(context: Context, kind: String, title: String, text: String): Boolean {
synchronized(works) {
works.remove(kind)
works[kind] = Work(title, text, -1)
}
return try {
val intent = Intent(context, BackgroundWorkService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
true
} catch (e: Exception) {
synchronized(works) { works.remove(kind) }
android.util.Log.w("BackgroundWorkService", "Cannot start $kind: ${e.message}")
false
}
}
/** [progress] is a percentage, or negative for indeterminate. */
fun update(kind: String, title: String, text: String, progress: Int) {
synchronized(works) {
if (!works.containsKey(kind)) return
works.remove(kind)
works[kind] = Work(title, text, progress.coerceAtMost(100))
}
mainHandler.post { instance?.refresh() }
}
fun stop(kind: String) {
synchronized(works) {
if (works.remove(kind) == null) return
}
mainHandler.post { instance?.refresh() }
}
fun isActive(kind: String): Boolean = synchronized(works) { works.containsKey(kind) }
}
private var wakeLock: PowerManager.WakeLock? = null
private var inForeground = false
private val renewWakeLock = object : Runnable {
override fun run() {
if (!inForeground) return
wakeLock?.acquire(WAKELOCK_TIMEOUT_MS)
mainHandler.postDelayed(this, WAKELOCK_RENEW_INTERVAL_MS)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
instance = this
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Background tasks",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Library scans and app updates in progress"
setShowBadge(false)
},
)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// startForegroundService requires startForeground even when the work
// already ended before this command arrived; refresh() then stops.
val current = latestWork() ?: Work("SpotiFLAC", "", -1)
if (!inForeground) {
val notification = buildNotification(current)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
inForeground = true
acquireWakeLock()
}
refresh()
return START_NOT_STICKY
}
// Android 15 limits dataSync services; give the time back and stop.
override fun onTimeout(startId: Int, fgsType: Int) {
synchronized(works) { works.clear() }
refresh()
}
override fun onDestroy() {
stopWork()
instance = null
super.onDestroy()
}
private fun latestWork(): Work? = synchronized(works) { works.values.lastOrNull() }
private fun refresh() {
if (!inForeground) return
val work = latestWork()
if (work == null) {
stopWork()
stopSelf()
return
}
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification(work))
}
private fun stopWork() {
mainHandler.removeCallbacks(renewWakeLock)
wakeLock?.let {
if (it.isHeld) {
try {
it.release()
} catch (e: RuntimeException) {
android.util.Log.w("BackgroundWorkService", "WakeLock release failed: ${e.message}")
}
}
}
wakeLock = null
if (inForeground) {
inForeground = false
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply {
setReferenceCounted(false)
acquire(WAKELOCK_TIMEOUT_MS)
}
mainHandler.postDelayed(renewWakeLock, WAKELOCK_RENEW_INTERVAL_MS)
}
private fun buildNotification(work: Work): Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(work.title)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
if (work.text.isNotEmpty()) {
builder.setContentText(work.text.lineSequence().first())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(work.text))
}
if (work.progress >= 0) {
builder.setProgress(100, work.progress, false)
} else {
builder.setProgress(0, 0, true)
}
return builder.build()
}
}
@@ -135,6 +135,9 @@ class MainActivity: FlutterFragmentActivity() {
"stopDownloadService",
"updateDownloadServiceProgress",
"isDownloadServiceRunning",
"startBackgroundWork",
"updateBackgroundWork",
"stopBackgroundWork",
"startNativeDownloadWorker",
"appendNativeDownloadWorkerRequests",
"finishNativeDownloadWorkerPreparation",
@@ -1781,6 +1784,29 @@ class MainActivity: FlutterFragmentActivity() {
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
"startBackgroundWork" -> {
result.success(
BackgroundWorkService.start(
this@MainActivity,
call.argument<String>("kind") ?: "",
call.argument<String>("title") ?: "",
call.argument<String>("text") ?: "",
),
)
}
"updateBackgroundWork" -> {
BackgroundWorkService.update(
call.argument<String>("kind") ?: "",
call.argument<String>("title") ?: "",
call.argument<String>("text") ?: "",
call.argument<Int>("progress") ?: -1,
)
result.success(null)
}
"stopBackgroundWork" -> {
BackgroundWorkService.stop(call.argument<String>("kind") ?: "")
result.success(null)
}
"startNativeDownloadWorker" -> {
val requestsJson = call.argument<String>("requests_json") ?: "[]"
val settingsJson = call.argument<String>("settings_json") ?: "{}"