diff --git a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt index 50a57ab..19fd8e1 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt @@ -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() { private var items: List = 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) diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt index 563dac5..dbd05a8 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -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) } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt index 14167d5..fcc170d 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/AppListOrdering.kt @@ -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, onlySeen: Boolean, seenAppsFirst: Boolean, - mode: AppRuleMode, ): List { 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) diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt index c2007f6..a573cb5 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LoggingRuleStore.kt @@ -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 { diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt index 176a8ca..a374a8f 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerComponentController.kt @@ -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) { + fun update(context: Context, rules: Collection, 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 diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt index bbbcd62..29a05e3 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicy.kt @@ -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): Boolean = rules.any { it.enabled } + fun shouldRun(rules: Collection, hasEnabledEventOverride: Boolean = false): Boolean = + rules.any { it.enabled } || hasEnabledEventOverride } diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/PerAppEventSettingsStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/PerAppEventSettingsStore.kt new file mode 100644 index 0000000..e5a8435 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/PerAppEventSettingsStore.kt @@ -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) { + 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) { + 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:" + } +} diff --git a/app/src/main/res/drawable/ic_filter_list.xml b/app/src/main/res/drawable/ic_filter_list.xml new file mode 100644 index 0000000..9672353 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt b/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt index 01d7307..45dc8f5 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/settings/AppListOrderingTest.kt @@ -4,14 +4,18 @@ import org.junit.Assert.assertEquals import org.junit.Test class AppListOrderingTest { - private fun app(label: String, seen: Boolean = false, selected: Boolean = false) = ListedApp(label, "pkg.$label", seen, selected) + private fun app( + label: String, + seen: Boolean = false, + selected: Boolean = false, + hasEventOverride: Boolean = false, + ) = ListedApp(label, "pkg.$label", seen, selected, hasEventOverride) @Test fun `seen apps are titled and repeated before the full list when requested`() { val items = AppListOrdering.items( listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)), onlySeen = false, seenAppsFirst = true, - mode = AppRuleMode.BLACKLIST, ) assertEquals(listOf("[Seen apps]", "Alpha", "Beta", "|", "[All apps]", "Alpha", "Beta", "Zulu"), items.labels()) } @@ -21,9 +25,8 @@ class AppListOrderingTest { listOf(app("Seen", seen = true), app("Chosen", selected = true), app("Hidden")), onlySeen = true, seenAppsFirst = true, - mode = AppRuleMode.WHITELIST, ) - assertEquals(listOf("[Seen apps]", "Seen", "|", "[Whitelisted apps]", "Chosen"), items.labels()) + assertEquals(listOf("[Seen apps]", "Seen", "|", "[Edited apps]", "Chosen"), items.labels()) } @Test fun `only seen and checked without grouping is one alphabetical list`() { @@ -31,17 +34,24 @@ class AppListOrderingTest { listOf(app("Zulu", seen = true), app("Beta", selected = true), app("Hidden")), onlySeen = true, seenAppsFirst = false, - mode = AppRuleMode.BLACKLIST, ) assertEquals(listOf("Beta", "Zulu"), items.labels()) } + @Test fun `unseen app with an event override is included as edited`() { + val items = AppListOrdering.items( + listOf(app("Seen", seen = true), app("Configured", hasEventOverride = true), app("Hidden")), + onlySeen = true, + seenAppsFirst = true, + ) + assertEquals(listOf("[Seen apps]", "Seen", "|", "[Edited apps]", "Configured"), items.labels()) + } + @Test fun `only seen apps omit section titles without unseen checked apps`() { val items = AppListOrdering.items( listOf(app("Zulu", seen = true), app("Alpha", seen = true), app("Hidden")), onlySeen = true, seenAppsFirst = true, - mode = AppRuleMode.BLACKLIST, ) assertEquals(listOf("Alpha", "Zulu"), items.labels()) } @@ -51,7 +61,6 @@ class AppListOrderingTest { listOf(app("Zulu", seen = true), app("Alpha"), app("Beta", selected = true)), onlySeen = false, seenAppsFirst = false, - mode = AppRuleMode.BLACKLIST, ) assertEquals(listOf("Alpha", "Beta", "Zulu"), items.labels()) } diff --git a/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt b/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt index 227a2ac..be3178a 100644 --- a/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt +++ b/app/src/test/java/se/ajpanton/notificationlog/settings/NotificationListenerPolicyTest.kt @@ -12,4 +12,13 @@ class NotificationListenerPolicyTest { @Test fun `listener stays off when every logging type is disabled`() { assertFalse(NotificationListenerPolicy.shouldRun(LoggingType.entries.map { LoggingRule(enabled = false) })) } + + @Test fun `listener runs for an enabled per-app event override`() { + assertTrue( + NotificationListenerPolicy.shouldRun( + LoggingType.entries.map { LoggingRule(enabled = false) }, + hasEnabledEventOverride = true, + ), + ) + } }