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 + )