Add per-app event logging overrides

This commit is contained in:
ajp_anton
2026-07-27 13:45:32 +00:00
parent 55057767c1
commit 1e4315d3dd
10 changed files with 229 additions and 24 deletions
@@ -1,6 +1,7 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.res.ColorStateList
import android.text.SpannableString
import android.text.Spanned
import android.text.style.StyleSpan
@@ -9,12 +10,16 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.capture.SeenApps
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
import se.ajpanton.notificationlog.settings.AppListItem
@@ -23,10 +28,14 @@ import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.ListedApp
import se.ajpanton.notificationlog.settings.AppFilterSettings
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
private var binding: FragmentEventSettingsBinding? = null
private lateinit var store: AppFilterSettingsStore
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var appAdapter: AppListAdapter
private var appLoadGeneration = 0
@@ -34,7 +43,8 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view)
store = AppFilterSettingsStore(requireContext())
appAdapter = AppListAdapter(::setPackageSelected)
perAppEventSettings = PerAppEventSettingsStore(requireContext())
appAdapter = AppListAdapter(::setPackageSelected, ::showPerAppEventSettings)
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = appAdapter
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
@@ -118,9 +128,10 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
packageName = info.packageName,
seen = info.packageName in seen,
selected = info.packageName in rule.selectedPackages,
hasEventOverride = perAppEventSettings.hasOverride(info.packageName),
)
}
val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst, rule.mode)
val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst)
activity?.runOnUiThread {
if (binding === currentBinding && generation == appLoadGeneration) {
appAdapter.submit(items)
@@ -140,8 +151,69 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
// Do not reload: an unchecked unseen row remains visible until the next requested refresh.
}
private fun showPerAppEventSettings(app: ListedApp) {
val eventTypes = listOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS)
val rules = LoggingRuleStore(requireContext())
val content = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), 0, dp(24), 0)
}
val useGlobal = SwitchMaterial(requireContext()).apply { text = "Use global" }
val eventToggles = eventTypes.associateWith { type ->
SwitchMaterial(requireContext()).apply {
text = type.label()
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply { marginStart = dp(24) }
}.also(content::addView)
}
content.addView(useGlobal, 0)
fun globalEvents() = eventTypes.associateWith { rules.ruleFor(it).enabled }
fun refresh() {
val useGlobalSetting = perAppEventSettings.usesGlobal(app.packageName)
useGlobal.setOnCheckedChangeListener(null)
useGlobal.isChecked = useGlobalSetting
eventToggles.forEach { (type, toggle) ->
toggle.setOnCheckedChangeListener(null)
toggle.isEnabled = !useGlobalSetting
toggle.isChecked = perAppEventSettings.isEnabled(
app.packageName,
type,
globalEvents().getValue(type),
)
toggle.setOnCheckedChangeListener { _, enabled ->
perAppEventSettings.setEnabled(app.packageName, type, enabled)
}
}
useGlobal.setOnCheckedChangeListener { _, enabled ->
if (enabled) perAppEventSettings.useGlobal(app.packageName)
else perAppEventSettings.startUsingOverride(app.packageName, globalEvents())
appAdapter.updateEventOverride(app.packageName, !enabled)
refresh()
}
}
refresh()
MaterialAlertDialogBuilder(requireContext())
.setTitle("When logging, only log")
.setView(content)
.setPositiveButton("Close", null)
.show()
}
private fun LoggingType.label() = when (this) {
LoggingType.APPEARING -> "Appearing"
LoggingType.DISAPPEARING -> "Disappearing"
LoggingType.EDITS -> "Edits"
else -> error("Only event types are shown in the per-app dialog")
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private class AppListAdapter(
private val onSelectedChanged: (String, Boolean) -> Unit,
private val onEventFilterClicked: (ListedApp) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: List<AppListItem> = emptyList()
@@ -162,6 +234,12 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
setPadding(0, dp(parent, 4), 0, dp(parent, 4))
}
val checkbox = CheckBox(parent.context)
val filter = ImageButton(parent.context).apply {
setImageResource(R.drawable.ic_filter_list)
contentDescription = "Edit logging events"
layoutParams = LinearLayout.LayoutParams(dp(parent, 48), dp(parent, 48))
setPadding(dp(parent, 12), dp(parent, 12), dp(parent, 12), dp(parent, 12))
}
val textColumn = LinearLayout(parent.context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
@@ -171,8 +249,9 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
textColumn.addView(label)
textColumn.addView(packageName)
row.addView(checkbox)
row.addView(filter)
row.addView(textColumn)
AppHolder(row, checkbox, label, packageName)
AppHolder(row, checkbox, filter, label, packageName)
} else if (viewType == SECTION_TITLE) {
SectionTitleHolder(TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(
@@ -208,6 +287,15 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
} else current
}
}
holder.filter.contentDescription = "Edit logging events for ${app.label}"
holder.filter.imageTintList = ColorStateList.valueOf(
MaterialColors.getColor(
holder.filter,
if (app.hasEventOverride) com.google.android.material.R.attr.colorPrimary
else com.google.android.material.R.attr.colorOnSurface,
),
)
holder.filter.setOnClickListener { onEventFilterClicked(app) }
}
if (holder is SectionTitleHolder && item is AppListItem.SectionTitle) {
holder.text.text = item.value
@@ -216,9 +304,21 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
override fun getItemCount(): Int = items.size
fun updateEventOverride(packageName: String, hasEventOverride: Boolean) {
items = items.map { item ->
if (item is AppListItem.App && item.value.packageName == packageName) {
AppListItem.App(item.value.copy(hasEventOverride = hasEventOverride))
} else {
item
}
}
notifyDataSetChanged()
}
private class AppHolder(
view: View,
val checkbox: CheckBox,
val filter: ImageButton,
val label: TextView,
val packageName: TextView,
) : RecyclerView.ViewHolder(view)
@@ -14,6 +14,7 @@ import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -24,6 +25,7 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var ruleStore: LoggingRuleStore
private lateinit var captureSettings: CaptureSettingsStore
private lateinit var appFilterStore: AppFilterSettingsStore
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var imageStore: EncryptedImageStore
override fun onCreate() {
@@ -32,6 +34,7 @@ class NotificationCaptureService : NotificationListenerService() {
ruleStore = LoggingRuleStore(this)
captureSettings = CaptureSettingsStore(this)
appFilterStore = AppFilterSettingsStore(this)
perAppEventSettings = PerAppEventSettingsStore(this)
imageStore = EncryptedImageStore(this)
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer")
@@ -126,8 +129,15 @@ class NotificationCaptureService : NotificationListenerService() {
.ifEmpty { null }
}
private fun allows(type: LoggingType, packageName: String): Boolean =
ruleStore.ruleFor(type).enabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
private fun allows(type: LoggingType, packageName: String): Boolean {
val globalEnabled = ruleStore.ruleFor(type).enabled
val eventEnabled = if (type in EVENT_TYPES) {
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
} else {
globalEnabled
}
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
}
private fun appName(packageName: String): String = try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
@@ -167,5 +177,6 @@ class NotificationCaptureService : NotificationListenerService() {
private companion object {
const val TAG = "NotificationCapture"
const val MAX_CONTENT_CHARACTERS = 16_000
val EVENT_TYPES = setOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS)
}
}
@@ -1,6 +1,14 @@
package se.ajpanton.notificationlog.settings
data class ListedApp(val label: String, val packageName: String, val seen: Boolean, val selected: Boolean)
data class ListedApp(
val label: String,
val packageName: String,
val seen: Boolean,
val selected: Boolean,
val hasEventOverride: Boolean = false,
) {
val edited: Boolean get() = selected || hasEventOverride
}
sealed interface AppListItem {
data class App(val value: ListedApp) : AppListItem
data class SectionTitle(val value: String) : AppListItem
@@ -12,22 +20,21 @@ object AppListOrdering {
allApps: List<ListedApp>,
onlySeen: Boolean,
seenAppsFirst: Boolean,
mode: AppRuleMode,
): List<AppListItem> {
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps
val shown = if (onlySeen) apps.filter { it.seen || it.edited } else apps
val seen = shown.filter { it.seen }
val selectedUnseen = shown.filter { !it.seen }
val editedUnseen = shown.filter { !it.seen }
if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App)
return buildList {
if (!onlySeen) add(AppListItem.SectionTitle("Seen apps"))
if (onlySeen && selectedUnseen.isNotEmpty()) add(AppListItem.SectionTitle("Seen apps"))
if (onlySeen && editedUnseen.isNotEmpty()) add(AppListItem.SectionTitle("Seen apps"))
seen.forEach { add(AppListItem.App(it)) }
if (onlySeen) {
if (selectedUnseen.isNotEmpty()) {
if (editedUnseen.isNotEmpty()) {
add(AppListItem.Separator)
add(AppListItem.SectionTitle(if (mode == AppRuleMode.BLACKLIST) "Blacklisted apps" else "Whitelisted apps"))
selectedUnseen.forEach { add(AppListItem.App(it)) }
add(AppListItem.SectionTitle("Edited apps"))
editedUnseen.forEach { add(AppListItem.App(it)) }
}
} else {
add(AppListItem.Separator)
@@ -28,7 +28,11 @@ 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))
NotificationListenerComponentController.update(
appContext,
LoggingType.entries.map(::ruleFor),
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
)
}
private companion object {
@@ -13,11 +13,11 @@ import se.ajpanton.notificationlog.capture.NotificationCaptureService
* 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>) {
fun update(context: Context, rules: Collection<LoggingRule>, hasEnabledEventOverride: Boolean = false) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules)) {
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
@@ -2,5 +2,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 }
fun shouldRun(rules: Collection<LoggingRule>, hasEnabledEventOverride: Boolean = false): Boolean =
rules.any { it.enabled } || hasEnabledEventOverride
}
@@ -0,0 +1,55 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
/** Event logging choices that replace the global event switches for one app. */
class PerAppEventSettingsStore(context: Context) {
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun usesGlobal(packageName: String): Boolean = !preferences.contains(key(packageName))
fun isEnabled(packageName: String, type: LoggingType, globalEnabled: Boolean): Boolean =
preferences.getStringSet(key(packageName), null)?.let { type.name in it } ?: globalEnabled
fun useGlobal(packageName: String) {
preferences.edit { remove(key(packageName)) }
updateListenerComponent()
}
fun startUsingOverride(packageName: String, globalEvents: Map<LoggingType, Boolean>) {
saveEvents(packageName, globalEvents.filterValues { it }.keys)
}
fun setEnabled(packageName: String, type: LoggingType, enabled: Boolean) {
val events = preferences.getStringSet(key(packageName), emptySet()).orEmpty().toMutableSet()
if (enabled) events.add(type.name) else events.remove(type.name)
preferences.edit { putStringSet(key(packageName), events) }
updateListenerComponent()
}
fun hasOverride(packageName: String): Boolean = !usesGlobal(packageName)
fun hasEnabledEventOverride(): Boolean = preferences.all
.filterKeys { it.startsWith(KEY_PREFIX) }
.values
.any { value -> (value as? Set<*>)?.isNotEmpty() == true }
private fun saveEvents(packageName: String, enabledEvents: Set<LoggingType>) {
preferences.edit { putStringSet(key(packageName), enabledEvents.mapTo(mutableSetOf()) { it.name }) }
updateListenerComponent()
}
private fun updateListenerComponent() {
val rules = LoggingType.entries.map { LoggingRuleStore(appContext).ruleFor(it) }
NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride())
}
private fun key(packageName: String) = "$KEY_PREFIX$packageName"
private companion object {
const val FILE_NAME = "per-app-event-settings"
const val KEY_PREFIX = "events:"
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M3,5h18v2H3zM6,11h12v2H6zM10,17h4v2h-4z" />
</vector>