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