Stop notification listener when all logging is disabled

This commit is contained in:
ajp_anton
2026-07-23 14:02:58 +00:00
parent 6ce7d65409
commit 50aebbd42b
5 changed files with 75 additions and 1 deletions
+9
View File
@@ -24,6 +24,15 @@ The app declares `QUERY_ALL_PACKAGES` solely to implement the settings pages'
restricted package-visibility policy and provide the required declaration; a
future distribution variant may need a narrower app-selection flow.
## Background behavior
Android owns the notification-listener connection; this app has no polling,
scheduled job, alarm, or boot receiver. When one or more logging types are
enabled and notification access has been granted, Android binds the listener
again after device boot. When every logging type is disabled, the app disables
that listener component: it has no background service and does not start at
boot. Re-enabling any logging type re-enables and asks Android to rebind it.
## Notification update policy
Each platform notification key is tracked independently. Group summaries are
@@ -5,7 +5,8 @@ import androidx.core.content.edit
/** Persists rule choices only; notification contents never enter SharedPreferences. */
class LoggingRuleStore(context: Context) {
private val preferences = context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun ruleFor(type: LoggingType): LoggingRule = LoggingRule(
enabled = preferences.getBoolean(key(type, "enabled"), true),
@@ -24,6 +25,7 @@ class LoggingRuleStore(context: Context) {
preferences.edit {
write(type, rule)
}
updateListenerComponent()
}
fun copy(from: LoggingType, targets: Set<LoggingType>) {
@@ -33,6 +35,7 @@ class LoggingRuleStore(context: Context) {
write(target, source.copy(selectedPackages = source.selectedPackages.toSet()))
}
}
updateListenerComponent()
}
private fun android.content.SharedPreferences.Editor.write(type: LoggingType, rule: LoggingRule) {
@@ -46,6 +49,10 @@ class LoggingRuleStore(context: Context) {
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
private fun updateListenerComponent() {
NotificationListenerComponentController.update(appContext, LoggingType.entries.map(::ruleFor))
}
private companion object {
const val FILE_NAME = "logging-rules"
}
@@ -0,0 +1,37 @@
package se.ajpanton.notificationlog.settings
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.service.notification.NotificationListenerService
import android.util.Log
import se.ajpanton.notificationlog.capture.NotificationCaptureService
/**
* Notification listeners are re-bound by Android after boot when their component
* is enabled and the user granted notification access. Disabling the component
* is therefore both the no-work battery mode and the no-start-on-boot mode.
*/
internal object NotificationListenerComponentController {
fun update(context: Context, rules: Collection<LoggingRule>) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
if (packageManager.getComponentEnabledSetting(component) == desired) return
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
try {
NotificationListenerService.requestRebind(component)
} catch (error: SecurityException) {
Log.w(TAG, "Notification access is not granted yet; Android will bind after the user grants it", error)
}
}
}
private const val TAG = "NotificationListenerControl"
}
@@ -0,0 +1,6 @@
package se.ajpanton.notificationlog.settings
/** The listener is useful only when at least one event type is enabled. */
object NotificationListenerPolicy {
fun shouldRun(rules: Collection<LoggingRule>): Boolean = rules.any { it.enabled }
}
@@ -0,0 +1,15 @@
package se.ajpanton.notificationlog.settings
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NotificationListenerPolicyTest {
@Test fun `listener runs when any logging type is enabled`() {
assertTrue(NotificationListenerPolicy.shouldRun(listOf(LoggingRule(enabled = false), LoggingRule(enabled = true))))
}
@Test fun `listener stays off when every logging type is disabled`() {
assertFalse(NotificationListenerPolicy.shouldRun(LoggingType.entries.map { LoggingRule(enabled = false) }))
}
}