Add per-app event logging overrides
This commit is contained in:
@@ -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)
|
||||
|
||||
+13
-2
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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>
|
||||
Reference in New Issue
Block a user