Add per-app event logging overrides
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package se.ajpanton.notificationlog
|
package se.ajpanton.notificationlog
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.content.res.ColorStateList
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.text.style.StyleSpan
|
import android.text.style.StyleSpan
|
||||||
@@ -9,12 +10,16 @@ import android.view.MotionEvent
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.CheckBox
|
import android.widget.CheckBox
|
||||||
|
import android.widget.ImageButton
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
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.capture.SeenApps
|
||||||
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
|
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
|
||||||
import se.ajpanton.notificationlog.settings.AppListItem
|
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.ListedApp
|
||||||
import se.ajpanton.notificationlog.settings.AppFilterSettings
|
import se.ajpanton.notificationlog.settings.AppFilterSettings
|
||||||
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
|
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) {
|
class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
|
||||||
private var binding: FragmentEventSettingsBinding? = null
|
private var binding: FragmentEventSettingsBinding? = null
|
||||||
private lateinit var store: AppFilterSettingsStore
|
private lateinit var store: AppFilterSettingsStore
|
||||||
|
private lateinit var perAppEventSettings: PerAppEventSettingsStore
|
||||||
private lateinit var appAdapter: AppListAdapter
|
private lateinit var appAdapter: AppListAdapter
|
||||||
private var appLoadGeneration = 0
|
private var appLoadGeneration = 0
|
||||||
|
|
||||||
@@ -34,7 +43,8 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding = FragmentEventSettingsBinding.bind(view)
|
binding = FragmentEventSettingsBinding.bind(view)
|
||||||
store = AppFilterSettingsStore(requireContext())
|
store = AppFilterSettingsStore(requireContext())
|
||||||
appAdapter = AppListAdapter(::setPackageSelected)
|
perAppEventSettings = PerAppEventSettingsStore(requireContext())
|
||||||
|
appAdapter = AppListAdapter(::setPackageSelected, ::showPerAppEventSettings)
|
||||||
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
|
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding!!.appList.adapter = appAdapter
|
binding!!.appList.adapter = appAdapter
|
||||||
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
|
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
|
||||||
@@ -118,9 +128,10 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
|
|||||||
packageName = info.packageName,
|
packageName = info.packageName,
|
||||||
seen = info.packageName in seen,
|
seen = info.packageName in seen,
|
||||||
selected = info.packageName in rule.selectedPackages,
|
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 {
|
activity?.runOnUiThread {
|
||||||
if (binding === currentBinding && generation == appLoadGeneration) {
|
if (binding === currentBinding && generation == appLoadGeneration) {
|
||||||
appAdapter.submit(items)
|
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.
|
// 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 class AppListAdapter(
|
||||||
private val onSelectedChanged: (String, Boolean) -> Unit,
|
private val onSelectedChanged: (String, Boolean) -> Unit,
|
||||||
|
private val onEventFilterClicked: (ListedApp) -> Unit,
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
private var items: List<AppListItem> = emptyList()
|
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))
|
setPadding(0, dp(parent, 4), 0, dp(parent, 4))
|
||||||
}
|
}
|
||||||
val checkbox = CheckBox(parent.context)
|
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 {
|
val textColumn = LinearLayout(parent.context).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
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(label)
|
||||||
textColumn.addView(packageName)
|
textColumn.addView(packageName)
|
||||||
row.addView(checkbox)
|
row.addView(checkbox)
|
||||||
|
row.addView(filter)
|
||||||
row.addView(textColumn)
|
row.addView(textColumn)
|
||||||
AppHolder(row, checkbox, label, packageName)
|
AppHolder(row, checkbox, filter, label, packageName)
|
||||||
} else if (viewType == SECTION_TITLE) {
|
} else if (viewType == SECTION_TITLE) {
|
||||||
SectionTitleHolder(TextView(parent.context).apply {
|
SectionTitleHolder(TextView(parent.context).apply {
|
||||||
layoutParams = RecyclerView.LayoutParams(
|
layoutParams = RecyclerView.LayoutParams(
|
||||||
@@ -208,6 +287,15 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
|
|||||||
} else current
|
} 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) {
|
if (holder is SectionTitleHolder && item is AppListItem.SectionTitle) {
|
||||||
holder.text.text = item.value
|
holder.text.text = item.value
|
||||||
@@ -216,9 +304,21 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
|
|||||||
|
|
||||||
override fun getItemCount(): Int = items.size
|
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(
|
private class AppHolder(
|
||||||
view: View,
|
view: View,
|
||||||
val checkbox: CheckBox,
|
val checkbox: CheckBox,
|
||||||
|
val filter: ImageButton,
|
||||||
val label: TextView,
|
val label: TextView,
|
||||||
val packageName: TextView,
|
val packageName: TextView,
|
||||||
) : RecyclerView.ViewHolder(view)
|
) : 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.NotificationRuleEvaluator
|
||||||
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
|
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
|
||||||
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
|
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
|
||||||
|
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
private lateinit var ruleStore: LoggingRuleStore
|
private lateinit var ruleStore: LoggingRuleStore
|
||||||
private lateinit var captureSettings: CaptureSettingsStore
|
private lateinit var captureSettings: CaptureSettingsStore
|
||||||
private lateinit var appFilterStore: AppFilterSettingsStore
|
private lateinit var appFilterStore: AppFilterSettingsStore
|
||||||
|
private lateinit var perAppEventSettings: PerAppEventSettingsStore
|
||||||
private lateinit var imageStore: EncryptedImageStore
|
private lateinit var imageStore: EncryptedImageStore
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -32,6 +34,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
ruleStore = LoggingRuleStore(this)
|
ruleStore = LoggingRuleStore(this)
|
||||||
captureSettings = CaptureSettingsStore(this)
|
captureSettings = CaptureSettingsStore(this)
|
||||||
appFilterStore = AppFilterSettingsStore(this)
|
appFilterStore = AppFilterSettingsStore(this)
|
||||||
|
perAppEventSettings = PerAppEventSettingsStore(this)
|
||||||
imageStore = EncryptedImageStore(this)
|
imageStore = EncryptedImageStore(this)
|
||||||
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||||
Thread(runnable, "notification-log-writer")
|
Thread(runnable, "notification-log-writer")
|
||||||
@@ -126,8 +129,15 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
.ifEmpty { null }
|
.ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun allows(type: LoggingType, packageName: String): Boolean =
|
private fun allows(type: LoggingType, packageName: String): Boolean {
|
||||||
ruleStore.ruleFor(type).enabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
|
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 {
|
private fun appName(packageName: String): String = try {
|
||||||
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||||
@@ -167,5 +177,6 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "NotificationCapture"
|
const val TAG = "NotificationCapture"
|
||||||
const val MAX_CONTENT_CHARACTERS = 16_000
|
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
|
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 {
|
sealed interface AppListItem {
|
||||||
data class App(val value: ListedApp) : AppListItem
|
data class App(val value: ListedApp) : AppListItem
|
||||||
data class SectionTitle(val value: String) : AppListItem
|
data class SectionTitle(val value: String) : AppListItem
|
||||||
@@ -12,22 +20,21 @@ object AppListOrdering {
|
|||||||
allApps: List<ListedApp>,
|
allApps: List<ListedApp>,
|
||||||
onlySeen: Boolean,
|
onlySeen: Boolean,
|
||||||
seenAppsFirst: Boolean,
|
seenAppsFirst: Boolean,
|
||||||
mode: AppRuleMode,
|
|
||||||
): List<AppListItem> {
|
): List<AppListItem> {
|
||||||
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
|
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 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)
|
if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App)
|
||||||
return buildList {
|
return buildList {
|
||||||
if (!onlySeen) add(AppListItem.SectionTitle("Seen apps"))
|
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)) }
|
seen.forEach { add(AppListItem.App(it)) }
|
||||||
if (onlySeen) {
|
if (onlySeen) {
|
||||||
if (selectedUnseen.isNotEmpty()) {
|
if (editedUnseen.isNotEmpty()) {
|
||||||
add(AppListItem.Separator)
|
add(AppListItem.Separator)
|
||||||
add(AppListItem.SectionTitle(if (mode == AppRuleMode.BLACKLIST) "Blacklisted apps" else "Whitelisted apps"))
|
add(AppListItem.SectionTitle("Edited apps"))
|
||||||
selectedUnseen.forEach { add(AppListItem.App(it)) }
|
editedUnseen.forEach { add(AppListItem.App(it)) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
add(AppListItem.Separator)
|
add(AppListItem.Separator)
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ class LoggingRuleStore(context: Context) {
|
|||||||
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
|
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
|
||||||
|
|
||||||
private fun updateListenerComponent() {
|
private fun updateListenerComponent() {
|
||||||
NotificationListenerComponentController.update(appContext, LoggingType.entries.map(::ruleFor))
|
NotificationListenerComponentController.update(
|
||||||
|
appContext,
|
||||||
|
LoggingType.entries.map(::ruleFor),
|
||||||
|
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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.
|
* is therefore both the no-work battery mode and the no-start-on-boot mode.
|
||||||
*/
|
*/
|
||||||
internal object NotificationListenerComponentController {
|
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 applicationContext = context.applicationContext
|
||||||
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
|
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
|
||||||
val packageManager = applicationContext.packageManager
|
val packageManager = applicationContext.packageManager
|
||||||
val desired = if (NotificationListenerPolicy.shouldRun(rules)) {
|
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
} else {
|
} else {
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
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. */
|
/** The listener is useful only when at least one event type is enabled. */
|
||||||
object NotificationListenerPolicy {
|
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>
|
||||||
@@ -4,14 +4,18 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class AppListOrderingTest {
|
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`() {
|
@Test fun `seen apps are titled and repeated before the full list when requested`() {
|
||||||
val items = AppListOrdering.items(
|
val items = AppListOrdering.items(
|
||||||
listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)),
|
listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)),
|
||||||
onlySeen = false,
|
onlySeen = false,
|
||||||
seenAppsFirst = true,
|
seenAppsFirst = true,
|
||||||
mode = AppRuleMode.BLACKLIST,
|
|
||||||
)
|
)
|
||||||
assertEquals(listOf("[Seen apps]", "Alpha", "Beta", "|", "[All apps]", "Alpha", "Beta", "Zulu"), items.labels())
|
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")),
|
listOf(app("Seen", seen = true), app("Chosen", selected = true), app("Hidden")),
|
||||||
onlySeen = true,
|
onlySeen = true,
|
||||||
seenAppsFirst = 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`() {
|
@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")),
|
listOf(app("Zulu", seen = true), app("Beta", selected = true), app("Hidden")),
|
||||||
onlySeen = true,
|
onlySeen = true,
|
||||||
seenAppsFirst = false,
|
seenAppsFirst = false,
|
||||||
mode = AppRuleMode.BLACKLIST,
|
|
||||||
)
|
)
|
||||||
assertEquals(listOf("Beta", "Zulu"), items.labels())
|
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`() {
|
@Test fun `only seen apps omit section titles without unseen checked apps`() {
|
||||||
val items = AppListOrdering.items(
|
val items = AppListOrdering.items(
|
||||||
listOf(app("Zulu", seen = true), app("Alpha", seen = true), app("Hidden")),
|
listOf(app("Zulu", seen = true), app("Alpha", seen = true), app("Hidden")),
|
||||||
onlySeen = true,
|
onlySeen = true,
|
||||||
seenAppsFirst = true,
|
seenAppsFirst = true,
|
||||||
mode = AppRuleMode.BLACKLIST,
|
|
||||||
)
|
)
|
||||||
assertEquals(listOf("Alpha", "Zulu"), items.labels())
|
assertEquals(listOf("Alpha", "Zulu"), items.labels())
|
||||||
}
|
}
|
||||||
@@ -51,7 +61,6 @@ class AppListOrderingTest {
|
|||||||
listOf(app("Zulu", seen = true), app("Alpha"), app("Beta", selected = true)),
|
listOf(app("Zulu", seen = true), app("Alpha"), app("Beta", selected = true)),
|
||||||
onlySeen = false,
|
onlySeen = false,
|
||||||
seenAppsFirst = false,
|
seenAppsFirst = false,
|
||||||
mode = AppRuleMode.BLACKLIST,
|
|
||||||
)
|
)
|
||||||
assertEquals(listOf("Alpha", "Beta", "Zulu"), items.labels())
|
assertEquals(listOf("Alpha", "Beta", "Zulu"), items.labels())
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -12,4 +12,13 @@ class NotificationListenerPolicyTest {
|
|||||||
@Test fun `listener stays off when every logging type is disabled`() {
|
@Test fun `listener stays off when every logging type is disabled`() {
|
||||||
assertFalse(NotificationListenerPolicy.shouldRun(LoggingType.entries.map { LoggingRule(enabled = false) }))
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user