From 6e6d9dd00e30734edc9650a4c5907a71d240d8e0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:46:21 +0700 Subject: [PATCH] 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. --- android/app/src/main/AndroidManifest.xml | 6 + .../zarz/spotiflac/BackgroundWorkService.kt | 218 ++++++++++++++++++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 26 +++ ios/Runner/AppDelegate.swift | 40 ++++ lib/providers/local_library_provider.dart | 6 + lib/services/notification_service.dart | 100 +++++++- lib/services/platform_bridge.dart | 36 +++ lib/widgets/update_dialog.dart | 48 ++-- 8 files changed, 455 insertions(+), 25 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt 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();