fix: Replace WorkManager with ForegroundService to eliminate excessive wakelocks

Replace MonitorWorker (CoroutineWorker) with MonitorService (ForegroundService)
to eliminate persistent ProcessorForegroundLck wakelock reported in Android Vitals.
Add BootCompletedReceiver for post-reboot auto-start.
Remove WorkManager dependency entirely.
This commit is contained in:
darken
2026-02-08 14:19:11 +01:00
committed by Matthias Urhahn
parent 5aa36b148b
commit ebfffb1536
11 changed files with 169 additions and 225 deletions
-1
View File
@@ -158,7 +158,6 @@ dependencies {
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
addNavigation()
addBaseWorkManager()
addTesting()
+12 -17
View File
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="eu.darken.capod">
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
@@ -9,6 +8,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
@@ -65,6 +66,14 @@
</intent-filter>
</receiver>
<receiver
android:name=".monitor.core.receiver.BootCompletedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".monitor.core.receiver.BluetoothEventReceiver"
android:enabled="true"
@@ -112,24 +121,10 @@
android:name=".common.debug.recording.ui.RecorderActivity"
android:theme="@style/AppThemeFloating" />
<!-- Worker stuff-->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:name=".monitor.core.worker.MonitorService"
android:foregroundServiceType="connectedDevice"
tools:node="merge" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
android:exported="false" />
</application>
</manifest>
+2 -24
View File
@@ -1,10 +1,7 @@
package eu.darken.capod
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.LogCatLogger
@@ -23,13 +20,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltAndroidApp
open class App : Application(), Configuration.Provider {
open class App : Application() {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var podMonitor: PodMonitor
@@ -45,9 +40,7 @@ open class App : Application(), Configuration.Provider {
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
appScope.launch {
monitorControl.startMonitor(forceStart = true)
}
monitorControl.startMonitor(forceStart = true)
podMonitor.devicesWithProfiles()
.distinctUntilChanged()
@@ -68,21 +61,6 @@ open class App : Application(), Configuration.Provider {
.launchIn(appScope)
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setMinimumLoggingLevel(
when {
BuildConfigWrap.DEBUG -> android.util.Log.VERBOSE
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.DEV -> android.util.Log.DEBUG
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.BETA -> android.util.Log.INFO
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.RELEASE -> android.util.Log.WARN
else -> android.util.Log.VERBOSE
}
)
.setWorkerFactory(workerFactory)
.build()
companion object {
internal val TAG = logTag("CAP")
}
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.Context
import android.media.AudioManager
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -30,11 +29,6 @@ class AndroidModule {
fun bluetoothManager(context: Context): BluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@Provides
@Singleton
fun workerManager(context: Context): WorkManager =
WorkManager.getInstance(context)
@Provides
@Singleton
fun audioManager(context: Context): AudioManager =
@@ -1,31 +0,0 @@
package eu.darken.capod.common.worker
import android.os.Parcel
import android.os.Parcelable
import androidx.work.Data
@Suppress("UNCHECKED_CAST")
inline fun <reified T : Parcelable> Data.getParcelable(key: String): T? {
val parcel = Parcel.obtain()
try {
val bytes = getByteArray(key) ?: return null
parcel.unmarshall(bytes, 0, bytes.size)
parcel.setDataPosition(0)
val creator = T::class.java.getField("CREATOR").get(null) as Parcelable.Creator<T>
return creator.createFromParcel(parcel)
} finally {
parcel.recycle()
}
}
fun Data.Builder.putParcelable(key: String, parcelable: Parcelable): Data.Builder {
val parcel = Parcel.obtain()
try {
parcelable.writeToParcel(parcel, 0)
putByteArray(key, parcel.marshall())
} finally {
parcel.recycle()
}
return this
}
@@ -8,21 +8,17 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.hasFeature
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class BluetoothEventReceiver : BroadcastReceiver() {
@Inject lateinit var monitorControl: MonitorControl
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent) {
log(TAG) { "onReceive($context, $intent)" }
@@ -47,12 +43,8 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log { "Device has the following we features we support $supportedFeatures" }
}
val pending = goAsync()
appScope.launch {
log(TAG) { "Starting monitor" }
monitorControl.startMonitor(bluetoothDevice, forceStart = false)
pending.finish()
}
log(TAG) { "Starting monitor" }
monitorControl.startMonitor(forceStart = false)
}
companion object {
@@ -0,0 +1,26 @@
package eu.darken.capod.monitor.core.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.monitor.core.worker.MonitorControl
import javax.inject.Inject
@AndroidEntryPoint
class BootCompletedReceiver : BroadcastReceiver() {
@Inject lateinit var monitorControl: MonitorControl
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
log(TAG) { "Boot completed, starting monitor." }
monitorControl.startMonitor(forceStart = false)
}
companion object {
private val TAG = logTag("Monitor", "BootReceiver")
}
}
@@ -1,51 +1,39 @@
package eu.darken.capod.monitor.core.worker
import android.bluetooth.BluetoothDevice
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.DispatcherProvider
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.withContext
import eu.darken.capod.common.startServiceCompat
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MonitorControl @Inject constructor(
private val workerManager: WorkManager,
private val dispatcherProvider: DispatcherProvider,
@ApplicationContext private val context: Context,
) {
suspend fun startMonitor(
bluetoothDevice: BluetoothDevice? = null,
fun startMonitor(
forceStart: Boolean = false,
): Unit = withContext(dispatcherProvider.IO) {
val workerData = Data.Builder().apply {
) {
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
try {
context.startServiceCompat(MonitorService.intent(context, forceStart))
log(TAG) { "Monitor start request sent." }
} catch (e: IllegalStateException) {
log(TAG, WARN) { "Failed to start monitor service: ${e.message}" }
}
}
}.build()
log(TAG, VERBOSE) { "Worker data: $workerData" }
val workRequest = OneTimeWorkRequestBuilder<MonitorWorker>().apply {
setInputData(workerData)
}.build()
log(TAG, VERBOSE) { "Worker request: $workRequest" }
val operation = workerManager.enqueueUniqueWork(
"${BuildConfigWrap.APPLICATION_ID}.monitor.worker",
if (forceStart) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
workRequest,
)
operation.result.get()
log(TAG) { "Monitor start request send." }
fun stopMonitor() {
log(TAG, VERBOSE) { "stopMonitor()" }
context.stopService(MonitorService.intent(context))
log(TAG) { "Monitor stop request sent." }
}
companion object {
private val TAG = logTag("Monitor", "Control")
}
}
}
@@ -1,13 +1,13 @@
package eu.darken.capod.monitor.core.worker
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -19,6 +19,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
@@ -33,6 +34,7 @@ import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
@@ -45,78 +47,87 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AndroidEntryPoint
class MonitorService : Service() {
@HiltWorker
class MonitorWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
private val dispatcherProvider: DispatcherProvider,
private val notifications: MonitorNotifications,
private val notificationManager: NotificationManager,
private val generalSettings: GeneralSettings,
private val permissionTool: PermissionTool,
private val podMonitor: PodMonitor,
private val bluetoothManager: BluetoothManager2,
private val playPause: PlayPause,
private val autoConnect: AutoConnect,
private val popUpReaction: PopUpReaction,
private val popUpWindow: PopUpWindow,
private val profilesRepo: DeviceProfilesRepo,
) : CoroutineWorker(context, params) {
@Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var notifications: MonitorNotifications
@Inject lateinit var notificationManager: NotificationManager
@Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var permissionTool: PermissionTool
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var bluetoothManager: BluetoothManager2
@Inject lateinit var playPause: PlayPause
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
private val workerScope = MonitorCoroutineScope()
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@Volatile private var monitorGeneration = 0
private var finishedWithError = false
@SuppressLint("InlinedApi")
override fun onCreate() {
super.onCreate()
log(TAG, VERBOSE) { "onCreate()" }
init {
log(TAG, VERBOSE) { "init(): workerId=$id" }
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return notifications.getForegroundInfo(null)
}
override suspend fun doWork(): Result = try {
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
doDoWork()
val duration = System.currentTimeMillis() - start
log(TAG, VERBOSE) { "Execution finished after ${duration}ms, $inputData" }
Result.success(inputData)
} catch (e: Throwable) {
if (e !is CancellationException) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
val notification = notifications.getStartupNotification()
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
Result.success()
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
}
} finally {
if (generalSettings.useExtraMonitorNotification.value && !generalSettings.keepConnectedNotificationAfterDisconnect.value) {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
log(TAG, VERBOSE) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId)" }
val forceStart = intent?.getBooleanExtra(EXTRA_FORCE_START, false) ?: false
if (monitoringJob?.isActive == true && !forceStart) {
log(TAG) { "Already monitoring and forceStart=false, keeping current session." }
return START_STICKY
}
val generation = ++monitorGeneration
monitorScope.coroutineContext.cancelChildren()
monitoringJob = monitorScope.launch {
try {
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
doMonitor()
} catch (e: CancellationException) {
log(TAG) { "Monitor cancelled." }
} catch (e: Exception) {
log(TAG, WARN) { "Failed to cancel connected notification: ${e.message}" }
Bugs.report(tag = TAG, "Monitor failed", exception = e)
} finally {
if (monitorGeneration == generation) {
log(TAG) { "Monitor finished, stopping service." }
stopSelf()
} else {
log(TAG) { "Monitor replaced, not stopping service." }
}
}
}
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
return START_STICKY
}
private suspend fun doDoWork() {
private suspend fun doMonitor() {
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
return
}
setForeground(notifications.getForegroundInfo(null))
val monitorJob = podMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.distinctUntilChanged()
@@ -136,13 +147,13 @@ class MonitorWorker @AssistedInject constructor(
.catch {
log(TAG, WARN) { "Pod Flow failed:\n${it.asLog()}" }
}
.launchIn(workerScope)
.launchIn(monitorScope)
permissionTool.missingPermissions
.flatMapLatest { missingPermsFlow ->
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
workerScope.coroutineContext.cancelChildren()
monitorScope.coroutineContext.cancelChildren()
emptyFlow()
} else {
combine(
@@ -163,7 +174,6 @@ class MonitorWorker @AssistedInject constructor(
@Suppress("UNCHECKED_CAST")
val devices = arguments[2] as Collection<BluetoothDevice2>
val connectedAddresses = devices.map { it.address }.toSet()
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
log(TAG) { "Monitor mode: $monitorMode" }
@@ -172,8 +182,7 @@ class MonitorWorker @AssistedInject constructor(
when (monitorMode) {
MonitorMode.MANUAL -> flow<Unit> {
// Cancel worker, ui scans manually
workerScope.coroutineContext.cancelChildren()
monitorScope.coroutineContext.cancelChildren()
}
MonitorMode.ALWAYS -> emptyFlow()
@@ -188,11 +197,11 @@ class MonitorWorker @AssistedInject constructor(
}
else -> {
log(TAG) { "No known Pods are connected, canceling worker soon." }
log(TAG) { "No known Pods are connected, stopping service soon." }
delay(15 * 1000)
log(TAG) { "Canceling worker now, still no Pods connected." }
log(TAG) { "Stopping service now, still no Pods connected." }
workerScope.coroutineContext.cancelChildren()
monitorScope.coroutineContext.cancelChildren()
}
}
}
@@ -201,7 +210,7 @@ class MonitorWorker @AssistedInject constructor(
.catch {
log(TAG, WARN) { "MonitorMode Flow failed:\n${it.asLog()}" }
}
.launchIn(workerScope)
.launchIn(monitorScope)
popUpReaction.monitor()
.onEach {
@@ -214,24 +223,48 @@ class MonitorWorker @AssistedInject constructor(
}
.setupCommonEventHandlers(TAG) { "popUpReaction" }
.catch { log(TAG, WARN) { "popUpReaction failed:\n${it.asLog()}" } }
.launchIn(workerScope)
.launchIn(monitorScope)
playPause.monitor()
.setupCommonEventHandlers(TAG) { "playPause" }
.catch { log(TAG, WARN) { "playPause failed:\n${it.asLog()}" } }
.launchIn(workerScope)
.launchIn(monitorScope)
autoConnect.monitor()
.setupCommonEventHandlers(TAG) { "autoConnect" }
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
.launchIn(workerScope)
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
}
override fun onDestroy() {
log(TAG, VERBOSE) { "onDestroy()" }
monitorScope.cancel("Service destroyed")
if (generalSettings.useExtraMonitorNotification.value && !generalSettings.keepConnectedNotificationAfterDisconnect.value) {
try {
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
} catch (e: Exception) {
log(TAG, WARN) { "Failed to cancel connected notification: ${e.message}" }
}
}
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
val TAG = logTag("Monitor", "Worker")
val TAG = logTag("Monitor", "Service")
private const val EXTRA_FORCE_START = "extra.force_start"
fun intent(context: Context, forceStart: Boolean = false): Intent {
return Intent(context, MonitorService::class.java).apply {
putExtra(EXTRA_FORCE_START, forceStart)
}
}
}
}
@@ -1,22 +1,18 @@
package eu.darken.capod.monitor.ui
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.work.ForegroundInfo
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.DualPodDevice
@@ -157,25 +153,9 @@ class MonitorNotifications @Inject constructor(
}.build()
}
suspend fun getForegroundInfo(podDevice: PodDevice?): ForegroundInfo = builderLock.withLock {
getBuilder(podDevice).apply {
setChannelId(NOTIFICATION_CHANNEL_ID)
}.toForegroundInfo()
}
@SuppressLint("InlinedApi")
private fun NotificationCompat.Builder.toForegroundInfo(): ForegroundInfo = if (hasApiLevel(29)) {
ForegroundInfo(
NOTIFICATION_ID,
this.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
)
} else {
ForegroundInfo(
NOTIFICATION_ID,
this.build()
)
}
fun getStartupNotification(): Notification = getBuilder(null).apply {
setChannelId(NOTIFICATION_CHANNEL_ID)
}.build()
companion object {
val TAG = logTag("Monitor", "Notifications")