diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 24b573b9..b00bce3b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -153,6 +153,12 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
+
diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt
new file mode 100644
index 00000000..625365bb
--- /dev/null
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt
@@ -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()
+ 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()
+ }
+}
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 26e2f362..0769190f 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
@@ -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("kind") ?: "",
+ call.argument("title") ?: "",
+ call.argument("text") ?: "",
+ ),
+ )
+ }
+ "updateBackgroundWork" -> {
+ BackgroundWorkService.update(
+ call.argument("kind") ?: "",
+ call.argument("title") ?: "",
+ call.argument("text") ?: "",
+ call.argument("progress") ?: -1,
+ )
+ result.success(null)
+ }
+ "stopBackgroundWork" -> {
+ BackgroundWorkService.stop(call.argument("kind") ?: "")
+ result.success(null)
+ }
"startNativeDownloadWorker" -> {
val requestsJson = call.argument("requests_json") ?: "[]"
val settingsJson = call.argument("settings_json") ?: "{}"
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 9cf44851..abffac74 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -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 = ["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 {
diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart
index 7ac64300..fbfb63e4 100644
--- a/lib/providers/local_library_provider.dart
+++ b/lib/providers/local_library_provider.dart
@@ -634,6 +634,9 @@ class LocalLibraryNotifier extends Notifier {
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 {
);
await _showScanFailedNotification(e.toString());
} finally {
+ await _notificationService.endBackgroundWork(
+ NotificationService.libraryScanWorkKind,
+ );
if (securityAccess != null) {
await PlatformBridge.stopAccessingIosBookmark(securityAccess);
_log.i('Stopped iOS security-scoped access');
diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart
index 6c5d9c83..c3c69bf3 100644
--- a/lib/services/notification_service.dart
+++ b/lib/services/notification_service.dart
@@ -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 _serviceNotificationKinds = {};
+
+ /// Keeps user-started work running when the screen turns off (Android
+ /// foreground service, iOS background task). Pair with [endBackgroundWork].
+ Future 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 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 beginLibraryScanWork() => beginBackgroundWork(
+ libraryScanWorkKind,
+ title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
+ );
+
+ Future beginUpdateDownloadWork({required String version}) =>
+ beginBackgroundWork(
+ updateDownloadWorkKind,
+ title:
+ _l10n?.notifDownloadingUpdate(version) ??
+ 'Downloading ${AppInfo.appName} v$version',
+ );
+
+ Future _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),
);
}
diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart
index 88017ad7..732b3f34 100644
--- a/lib/services/platform_bridge.dart
+++ b/lib/services/platform_bridge.dart
@@ -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 startBackgroundWork(
+ String kind, {
+ required String title,
+ String text = '',
+ }) async {
+ final started = await _channel.invokeMethod('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 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 stopBackgroundWork(String kind) async {
+ await _channel.invokeMethod('stopBackgroundWork', {'kind': kind});
+ }
+
static Future updateDownloadServiceProgress({
required String trackName,
required String artistName,
diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart
index aad843f9..4bdc59fd 100644
--- a/lib/widgets/update_dialog.dart
+++ b/lib/widgets/update_dialog.dart
@@ -69,26 +69,38 @@ class _UpdateDialogState extends State {
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();