From 071d72b2558e027203cfccce0846e5646afd1b3b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:41:08 +0700 Subject: [PATCH] refactor(android): share the foreground wake lock and start helper between the two services DownloadService and BackgroundWorkService each built the same partial wake lock (30-minute timeout, 15-minute renew, not reference-counted), the same dataSync startForeground SDK branch and the same content PendingIntent to MainActivity. ForegroundWorkLock now holds the lock and the shared constants, and two small helpers cover the start branch and the intent. The renew strategies stay per service: DownloadService renews on its progress and maintenance calls once 15 minutes have passed, while BackgroundWorkService re-acquires from a main-thread Handler timer. Wake-lock tags, channel IDs, notification IDs, intent flags and request codes are unchanged, and the verification intent keeps its own request code and extras. --- .../zarz/spotiflac/BackgroundWorkService.kt | 48 ++------ .../com/zarz/spotiflac/DownloadService.kt | 65 +--------- .../com/zarz/spotiflac/ForegroundWorkLock.kt | 112 ++++++++++++++++++ 3 files changed, 126 insertions(+), 99 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/ForegroundWorkLock.kt diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt index 625365bb..9364ce26 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/BackgroundWorkService.kt @@ -3,16 +3,13 @@ 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 /** @@ -31,8 +28,6 @@ class BackgroundWorkService : Service() { 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()) @@ -84,13 +79,13 @@ class BackgroundWorkService : Service() { fun isActive(kind: String): Boolean = synchronized(works) { works.containsKey(kind) } } - private var wakeLock: PowerManager.WakeLock? = null + private val wakeLock = ForegroundWorkLock(this, WAKELOCK_TAG, "BackgroundWorkService") 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) + wakeLock.reacquire() + mainHandler.postDelayed(this, ForegroundWorkLock.RENEW_INTERVAL_MS) } } @@ -119,15 +114,7 @@ class BackgroundWorkService : Service() { 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) - } + startDataSyncForeground(NOTIFICATION_ID, notification) inForeground = true acquireWakeLock() } @@ -163,16 +150,7 @@ class BackgroundWorkService : Service() { 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 + wakeLock.release() if (inForeground) { inForeground = false stopForeground(STOP_FOREGROUND_REMOVE) @@ -180,22 +158,12 @@ class BackgroundWorkService : Service() { } 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) + if (!wakeLock.acquireIfNotHeld()) return + mainHandler.postDelayed(renewWakeLock, ForegroundWorkLock.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 pendingIntent = mainActivityContentIntent() val builder = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(work.title) .setSmallIcon(android.R.drawable.stat_sys_download) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index 9d3e3159..b3bb524d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -7,12 +7,10 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent -import android.content.pm.ServiceInfo import android.net.ConnectivityManager import android.net.Network import android.os.Build import android.os.IBinder -import android.os.PowerManager import android.os.SystemClock import android.util.AtomicFile import androidx.core.app.NotificationCompat @@ -56,8 +54,6 @@ class DownloadService : Service() { private const val DOWNLOAD_RESULT_NOTIFICATION_ID = 1 private const val VERIFICATION_REQUIRED_NOTIFICATION_ID = 4 private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock" - private const val WAKELOCK_RENEW_MS = 30 * 60 * 1000L - private const val WAKELOCK_RENEW_INTERVAL_MS = 15 * 60 * 1000L const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD" const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD" @@ -339,8 +335,7 @@ class DownloadService : Service() { @Volatile internal var pendingNativeItemsSnapshotJob: Job? = null private var nativeWorkerRequestChannel: Channel? = null @Volatile private var nativeWorkerPreparationComplete = true - private var wakeLock: PowerManager.WakeLock? = null - @Volatile private var wakeLockLastRenewedAt = 0L + private val wakeLock = ForegroundWorkLock(this, WAKELOCK_TAG, "DownloadService") private var currentTrackName = "" private var currentArtistName = "" internal var currentStatus = "preparing" @@ -668,15 +663,7 @@ class DownloadService : Service() { ensureWakeLock() val notification = buildNotification(0, 0) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } + startDataSyncForeground(NOTIFICATION_ID, notification) pushWidgetState(0, 0) } @@ -1461,42 +1448,12 @@ class DownloadService : Service() { @Synchronized private fun ensureWakeLock() { - val existingWakeLock = wakeLock - if (existingWakeLock?.isHeld == true) { - val now = SystemClock.elapsedRealtime() - if (now - wakeLockLastRenewedAt >= WAKELOCK_RENEW_INTERVAL_MS) { - existingWakeLock.acquire(WAKELOCK_RENEW_MS) - wakeLockLastRenewedAt = now - } - return - } - if (existingWakeLock != null) { - wakeLock = null - } - - val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager - wakeLock = powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - WAKELOCK_TAG - ).apply { - setReferenceCounted(false) - acquire(WAKELOCK_RENEW_MS) - } - wakeLockLastRenewedAt = SystemClock.elapsedRealtime() + wakeLock.ensure() } @Synchronized private fun releaseWakeLock() { - val existingWakeLock = wakeLock - wakeLock = null - wakeLockLastRenewedAt = 0L - if (existingWakeLock?.isHeld == true) { - try { - existingWakeLock.release() - } catch (e: RuntimeException) { - android.util.Log.w("DownloadService", "WakeLock release failed: ${e.message}") - } - } + wakeLock.release() } @Synchronized @@ -1629,12 +1586,7 @@ class DownloadService : Service() { } private fun buildNotification(progress: Long, total: Long): Notification { - val pendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = mainActivityContentIntent() val title = if (queueCount > 1) { "Downloading $queueCount tracks" @@ -1690,12 +1642,7 @@ class DownloadService : Service() { } private fun showNativeQueueComplete(counts: NativeWorkerCounts) { - val pendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) + val pendingIntent = mainActivityContentIntent() val title = if (counts.failed > 0) { "Downloads Finished (${counts.completed} done, ${counts.failed} failed)" } else { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/ForegroundWorkLock.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/ForegroundWorkLock.kt new file mode 100644 index 00000000..d5722d40 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/ForegroundWorkLock.kt @@ -0,0 +1,112 @@ +package com.zarz.spotiflac + +import android.app.Notification +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.PowerManager +import android.os.SystemClock + +/** + * Partial wake lock held by a dataSync foreground service while it works. + * + * The lock is not reference-counted and every acquire uses a 30-minute + * timeout, so a lock that is never released expires on its own. Callers keep + * it alive by renewing it at least every 15 minutes. The class is not + * thread-safe; callers synchronize as their service requires. + */ +internal class ForegroundWorkLock( + private val context: Context, + private val tag: String, + private val logTag: String, +) { + companion object { + const val TIMEOUT_MS = 30 * 60 * 1000L + const val RENEW_INTERVAL_MS = 15 * 60 * 1000L + } + + private var wakeLock: PowerManager.WakeLock? = null + @Volatile private var lastRenewedAt = 0L + + /** + * Acquires a fresh lock unless one is held. When one is held it is renewed + * once [RENEW_INTERVAL_MS] has passed since the last acquire. + */ + fun ensure() { + val existingWakeLock = wakeLock + if (existingWakeLock?.isHeld == true) { + val now = SystemClock.elapsedRealtime() + if (now - lastRenewedAt >= RENEW_INTERVAL_MS) { + existingWakeLock.acquire(TIMEOUT_MS) + lastRenewedAt = now + } + return + } + if (existingWakeLock != null) { + wakeLock = null + } + acquireNew() + } + + /** Acquires a fresh lock unless one is held; returns false when one was held. */ + fun acquireIfNotHeld(): Boolean { + if (wakeLock?.isHeld == true) return false + acquireNew() + return true + } + + /** Re-acquires the current lock, if any, for another [TIMEOUT_MS]. */ + fun reacquire() { + wakeLock?.acquire(TIMEOUT_MS) + } + + fun release() { + val existingWakeLock = wakeLock + wakeLock = null + lastRenewedAt = 0L + if (existingWakeLock?.isHeld == true) { + try { + existingWakeLock.release() + } catch (e: RuntimeException) { + android.util.Log.w(logTag, "WakeLock release failed: ${e.message}") + } + } + } + + private fun acquireNew() { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + tag + ).apply { + setReferenceCounted(false) + acquire(TIMEOUT_MS) + } + lastRenewedAt = SystemClock.elapsedRealtime() + } +} + +/** Promotes the service to the foreground with the dataSync type where supported. */ +internal fun Service.startDataSyncForeground(notificationId: Int, notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(notificationId, notification) + } +} + +/** Content intent that opens [MainActivity] from a service notification. */ +internal fun Context.mainActivityContentIntent(): PendingIntent = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + )