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") ?: "{}"
+40
View File
@@ -39,6 +39,9 @@ import UniformTypeIdentifiers
/// Main-thread only.
private var downloadsActive = false
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
/// Library scans and similar user-started work, by kind. Separate from
/// downloads so ending one never drops the other's assertion.
private var backgroundWorkTasks: [String: UIBackgroundTaskIdentifier] = [:]
/// Strong reference to the in-flight ASWebAuthenticationSession; the
/// session is deallocated (and its sheet dismissed) without it.
@@ -257,6 +260,7 @@ import UniformTypeIdentifiers
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
let osMethods: Set<String> = ["getBackendImplementations", "startWebAuthSession", "beginBackgroundDownloadTask", "endBackgroundDownloadTask",
"startBackgroundWork", "updateBackgroundWork", "stopBackgroundWork",
"pickIosDirectory", "startAccessingIosBookmark", "stopAccessingIosBookmark", "downloadCoverToFile", "releaseMemory", "releaseMemoryUnderPressure",
"setLibraryCoverCacheDir", "scanLibraryFolderToNDJSONFile", "scanLibraryFolderIncremental",
"getLibraryScanProgress", "cancelLibraryScan", "parseCueSheet", "extractCoverToFile",
@@ -293,6 +297,19 @@ import UniformTypeIdentifiers
endBackgroundDownloadTask()
result(nil)
return
case "startBackgroundWork":
let kind = (call.arguments as? [String: Any])?["kind"] as? String ?? ""
beginBackgroundWorkTask(kind: kind)
result(true)
return
case "updateBackgroundWork":
result(nil)
return
case "stopBackgroundWork":
let kind = (call.arguments as? [String: Any])?["kind"] as? String ?? ""
endBackgroundWorkTask(kind: kind)
result(nil)
return
case "pickIosDirectory":
pickIosDirectory(result: result)
return
@@ -419,6 +436,29 @@ import UniformTypeIdentifiers
}
}
/// iOS grants a short grace period after the app leaves the foreground,
/// enough for a screen lock during a small scan. On expiry a scan is
/// cancelled cleanly instead of being suspended mid-write.
private func beginBackgroundWorkTask(kind: String) {
guard !kind.isEmpty, backgroundWorkTasks[kind] == nil else { return }
backgroundWorkTasks[kind] = UIApplication.shared.beginBackgroundTask(
withName: "SpotiFLAC-\(kind)"
) { [weak self] in
NSLog("SpotiFLAC: \(kind) background task expired")
if kind == "library_scan" {
try? self?.coreBackend.cancelLibraryScan()
}
self?.endBackgroundWorkTask(kind: kind)
}
}
private func endBackgroundWorkTask(kind: String) {
guard let task = backgroundWorkTasks.removeValue(forKey: kind) else { return }
if task != .invalid {
UIApplication.shared.endBackgroundTask(task)
}
}
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
switch call.method {
@@ -634,6 +634,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanningSourceId: activeSourceId,
);
_resetScanNotificationTracking();
// Hold before the first progress notification so Android shows a single
// foreground-service notification; released in the final cleanup below.
await _notificationService.beginLibraryScanWork();
if (_shouldShowScanProgressNotification(
progress: 0,
totalFiles: 0,
@@ -944,6 +947,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
await _showScanFailedNotification(e.toString());
} finally {
await _notificationService.endBackgroundWork(
NotificationService.libraryScanWorkKind,
);
if (securityAccess != null) {
await PlatformBridge.stopAccessingIosBookmark(securityAccess);
_log.i('Stopped iOS security-scoped access');
+93 -7
View File
@@ -6,6 +6,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/verification_notification.dart';
class NotificationService {
@@ -30,6 +31,72 @@ class NotificationService {
String get embeddingMetadataLabel =>
_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...';
static const String libraryScanWorkKind = 'library_scan';
static const String updateDownloadWorkKind = 'app_update';
// Kinds whose progress the Android foreground-service notification shows,
// replacing the plugin's progress notification so only one is visible.
final Set<String> _serviceNotificationKinds = {};
/// Keeps user-started work running when the screen turns off (Android
/// foreground service, iOS background task). Pair with [endBackgroundWork].
Future<void> beginBackgroundWork(String kind, {required String title}) async {
if (!Platform.isAndroid && !Platform.isIOS) return;
try {
final started = await PlatformBridge.startBackgroundWork(
kind,
title: title,
);
if (started && Platform.isAndroid) _serviceNotificationKinds.add(kind);
} catch (e) {
debugPrint('Background work $kind could not start: $e');
}
}
Future<void> endBackgroundWork(String kind) async {
if (!Platform.isAndroid && !Platform.isIOS) return;
_serviceNotificationKinds.remove(kind);
try {
await PlatformBridge.stopBackgroundWork(kind);
} catch (e) {
debugPrint('Background work $kind could not stop: $e');
}
}
Future<void> beginLibraryScanWork() => beginBackgroundWork(
libraryScanWorkKind,
title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
);
Future<void> beginUpdateDownloadWork({required String version}) =>
beginBackgroundWork(
updateDownloadWorkKind,
title:
_l10n?.notifDownloadingUpdate(version) ??
'Downloading ${AppInfo.appName} v$version',
);
Future<bool> _showInBackgroundWork(
String kind, {
required String title,
required String body,
required int progress,
}) async {
if (!_serviceNotificationKinds.contains(kind)) return false;
try {
await PlatformBridge.updateBackgroundWork(
kind,
title: title,
text: body,
progress: progress,
);
return true;
} catch (e) {
debugPrint('Background work $kind notification failed: $e');
return false;
}
}
static const int downloadProgressId = 1;
static const int updateDownloadId = 2;
static const int libraryScanId = 3;
@@ -402,10 +469,19 @@ class NotificationService {
final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile'
: progressBody;
final title = _l10n?.notifScanningLibrary ?? 'Scanning local library';
if (await _showInBackgroundWork(
libraryScanWorkKind,
title: title,
body: body,
progress: percentage,
)) {
return;
}
await _showSafely(
id: libraryScanId,
title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
title: title,
body: body,
details: _details(library: true, progress: percentage),
);
@@ -473,15 +549,25 @@ class NotificationService {
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
final title =
_l10n?.notifDownloadingUpdate(version) ??
'Downloading ${AppInfo.appName} v$version';
final body =
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%';
if (await _showInBackgroundWork(
updateDownloadWorkKind,
title: title,
body: body,
progress: total > 0 ? percentage : -1,
)) {
return;
}
await _showSafely(
id: updateDownloadId,
title:
_l10n?.notifDownloadingUpdate(version) ??
'Downloading ${AppInfo.appName} v$version',
body:
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%',
title: title,
body: body,
details: _details(progress: percentage),
);
}
+36
View File
@@ -1328,6 +1328,42 @@ class PlatformBridge {
await _channel.invokeMethod('stopDownloadService');
}
/// Keeps user-started work of [kind] alive with the screen off: an Android
/// foreground service, or an iOS background-task grace period. Returns
/// whether the platform accepted it.
static Future<bool> startBackgroundWork(
String kind, {
required String title,
String text = '',
}) async {
final started = await _channel.invokeMethod<bool>('startBackgroundWork', {
'kind': kind,
'title': title,
'text': text,
});
return started ?? false;
}
/// Updates the Android work notification; [progress] is a percentage, or
/// negative while indeterminate.
static Future<void> updateBackgroundWork(
String kind, {
required String title,
String text = '',
int progress = -1,
}) async {
await _channel.invokeMethod('updateBackgroundWork', {
'kind': kind,
'title': title,
'text': text,
'progress': progress,
});
}
static Future<void> stopBackgroundWork(String kind) async {
await _channel.invokeMethod('stopBackgroundWork', {'kind': kind});
}
static Future<void> updateDownloadServiceProgress({
required String trackName,
required String artistName,
+30 -18
View File
@@ -69,26 +69,38 @@ class _UpdateDialogState extends State<UpdateDialog> {
final notificationService = NotificationService();
final filePath = await ApkDownloader.downloadApk(
url: apkUrl,
// Without a foreground service the download freezes when the screen
// turns off; its notification then carries the progress.
await notificationService.beginUpdateDownloadWork(
version: widget.updateInfo.version,
expectedSha256: widget.updateInfo.apkSha256,
onProgress: (received, total) {
if (mounted) {
setState(() {
_progress = total > 0 ? received / total : 0;
final receivedMB = formatMegabytes(received);
final totalMB = formatMegabytes(total);
_statusText = '$receivedMB / $totalMB MB';
});
}
notificationService.showUpdateDownloadProgress(
version: widget.updateInfo.version,
received: received,
total: total,
);
},
);
final String? filePath;
try {
filePath = await ApkDownloader.downloadApk(
url: apkUrl,
version: widget.updateInfo.version,
expectedSha256: widget.updateInfo.apkSha256,
onProgress: (received, total) {
if (mounted) {
setState(() {
_progress = total > 0 ? received / total : 0;
final receivedMB = formatMegabytes(received);
final totalMB = formatMegabytes(total);
_statusText = '$receivedMB / $totalMB MB';
});
}
notificationService.showUpdateDownloadProgress(
version: widget.updateInfo.version,
received: received,
total: total,
);
},
);
} finally {
await notificationService.endBackgroundWork(
NotificationService.updateDownloadWorkKind,
);
}
if (filePath != null) {
await notificationService.cancelUpdateNotification();