Add per-app alert rule management

This commit is contained in:
ajp_anton
2026-08-18 01:52:31 +00:00
parent 2f546afc89
commit ce49f21739
12 changed files with 318 additions and 57 deletions
@@ -0,0 +1,80 @@
package se.ajpanton.notificationsmaster
import android.content.Context
import android.view.View
import android.widget.ArrayAdapter
import android.widget.CheckBox
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
import se.ajpanton.notificationsmaster.alerts.AlertRule
import se.ajpanton.notificationsmaster.alerts.AlertRuleDefinition
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.AlertTextField
import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher
import se.ajpanton.notificationsmaster.alerts.AlertTextMode
object AlertRuleDialog {
fun show(
context: Context,
configuration: AlertConfiguration,
existing: AlertRule?,
apps: List<InstalledApp>? = null,
onSave: (String?, AlertRuleDefinition) -> Unit,
) {
val form = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(context, 24), 0, dp(context, 24), 0) }
val app = apps?.let {
Spinner(context).apply {
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, it)
existing?.let { rule -> setSelection(apps.indexOfFirst { choice -> choice.packageName == rule.packageName }.coerceAtLeast(0)) }
}.also(form::addView)
}
val profile = Spinner(context).apply {
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, configuration.profiles.map { it.name })
existing?.let { rule -> setSelection(configuration.profiles.indexOfFirst { it.id == rule.profileId }.coerceAtLeast(0)) }
}
val post = CheckBox(context).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources }
val update = CheckBox(context).apply { text = "Edits"; isChecked = existing == null || AlertSource.NOTIFICATION_UPDATE in existing.sources }
val pattern = EditText(context).apply { hint = "Text to match (optional)"; setText(existing?.matcher?.value) }
val regex = SwitchMaterial(context).apply { text = "Use regular expression"; isChecked = existing?.matcher?.mode == AlertTextMode.REGEX }
val protected = SwitchMaterial(context).apply { text = "Play to completion"; isChecked = existing?.playToCompletion == true }
val dnd = SwitchMaterial(context).apply { text = "Allow during DND when supported"; isChecked = existing?.allowDuringDnd == true }
listOf(profile, post, update, pattern, regex, protected, dnd).forEach(form::addView)
val dialog = MaterialAlertDialogBuilder(context).setTitle(if (existing == null) "Add rule" else "Edit rule")
.setView(form).setNegativeButton("Cancel", null).setPositiveButton("Save", null).show()
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
val sources = buildSet {
if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE)
}
if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
val value = pattern.text.toString().trim()
val matcher = runCatching {
AlertTextMatcher(
AlertTextField.ANY_TEXT,
if (value.isEmpty()) AlertTextMode.ANY else if (regex.isChecked) AlertTextMode.REGEX else AlertTextMode.CONTAINS,
value,
)
}.getOrElse {
pattern.error = "Invalid regular expression"
return@setOnClickListener
}
val definition = AlertRuleDefinition(
sources,
matcher,
AlertOutcome.PLAY_PROFILE,
configuration.profiles[profile.selectedItemPosition].id,
protected.isChecked,
dnd.isChecked,
)
onSave(app?.selectedItem?.let { it as InstalledApp }?.packageName, definition)
dialog.dismiss()
}
}
private fun dp(context: Context, value: Int) = (value * context.resources.displayMetrics.density).toInt()
}
@@ -0,0 +1,89 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertRule
import se.ajpanton.notificationsmaster.alerts.AlertRuleEditor
import se.ajpanton.notificationsmaster.databinding.FragmentAppRulesBinding
class AppRulesFragment : Fragment(R.layout.fragment_app_rules) {
private var binding: FragmentAppRulesBinding? = null
private lateinit var packageName: String
private lateinit var store: AlertConfigurationStore
private val adapter = RuleAdapter(::edit)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
packageName = requireArguments().getString(ARG_PACKAGE)!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAppRulesBinding.bind(view)
store = AlertConfigurationStore(requireContext())
binding!!.rules.adapter = adapter
binding!!.addRule.setOnClickListener { edit(null) }
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
override fun onMove(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
adapter.move(holder.bindingAdapterPosition, target.bindingAdapterPosition)
saveOrder()
return true
}
override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit
}).attachToRecyclerView(binding!!.rules)
refresh()
}
override fun onResume() {
super.onResume()
activity?.title = InstalledApps.name(requireContext(), packageName)
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun refresh() = adapter.submit(store.load().rules.filter { it.packageName == packageName }.sortedBy { it.order })
private fun edit(existing: AlertRule?) {
val configuration = store.load()
if (configuration.profiles.isEmpty()) {
android.widget.Toast.makeText(requireContext(), "Create a profile first", android.widget.Toast.LENGTH_LONG).show()
return
}
AlertRuleDialog.show(requireContext(), configuration, existing) { _, definition ->
store.save(if (existing == null) AlertRuleEditor.addForApps(configuration, listOf(packageName), definition) else AlertRuleEditor.updateForApp(configuration, existing.id, definition))
refresh()
}
}
private fun saveOrder() {
store.save(AlertRuleEditor.reorderForApp(store.load(), packageName, adapter.items.map { it.id }))
}
private class RuleAdapter(private val onClick: (AlertRule) -> Unit) : RecyclerView.Adapter<RuleAdapter.Holder>() {
var items = emptyList<AlertRule>(); private set
fun submit(rules: List<AlertRule>) { items = rules; notifyDataSetChanged() }
fun move(from: Int, to: Int) { items = items.toMutableList().apply { add(to, removeAt(from)) }; notifyItemMoved(from, to) }
override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int) = Holder(TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
val padding = (16 * parent.resources.displayMetrics.density).toInt()
setPadding(padding, padding, padding, padding); textSize = 16f; isClickable = true
})
override fun onBindViewHolder(holder: Holder, position: Int) {
val rule = items[position]
holder.text.text = rule.name ?: "Rule ${position + 1}" + "\n" + rule.sources.joinToString { if (it.name == "NOTIFICATION_POST") "Appearing" else "Edits" } + rule.matcher.value.takeIf { it.isNotBlank() }?.let { " • contains $it" }.orEmpty()
holder.text.setOnClickListener { onClick(rule) }
}
override fun getItemCount() = items.size
class Holder(val text: TextView) : RecyclerView.ViewHolder(text)
}
companion object {
private const val ARG_PACKAGE = "package"
fun newInstance(packageName: String) = AppRulesFragment().apply { arguments = bundleOf(ARG_PACKAGE to packageName) }
}
}
@@ -0,0 +1,46 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.Button
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.databinding.FragmentAppsBinding
class AppsFragment : Fragment(R.layout.fragment_apps) {
private var binding: FragmentAppsBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAppsBinding.bind(view)
refresh()
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_apps)
refresh()
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun refresh() {
val container = binding?.apps ?: return
val context = requireContext()
val grouped = AlertConfigurationStore(context).load().rules.groupBy { it.packageName }
container.removeAllViews()
grouped.keys.sortedBy { InstalledApps.name(context, it).lowercase() }.forEach { packageName ->
container.addView(Button(context).apply {
val count = grouped.getValue(packageName).size
text = InstalledApps.name(context, packageName) + "\n" + packageName + "" + count + if (count == 1) " rule" else " rules"
isAllCaps = false
setOnClickListener { open(packageName) }
})
}
}
private fun open(packageName: String) = parentFragmentManager.commit {
replace(R.id.content_frame, AppRulesFragment.newInstance(packageName))
addToBackStack(null)
}
}
@@ -0,0 +1,17 @@
package se.ajpanton.notificationsmaster
import android.content.Context
data class InstalledApp(val name: String, val packageName: String) {
override fun toString() = "$name ($packageName)"
}
object InstalledApps {
fun all(context: Context): List<InstalledApp> = context.packageManager.getInstalledApplications(0).map {
InstalledApp(context.packageManager.getApplicationLabel(it).toString(), it.packageName)
}.sortedBy { it.name.lowercase() }
fun name(context: Context, packageName: String): String = runCatching {
context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(packageName, 0)).toString()
}.getOrDefault(packageName)
}
@@ -157,6 +157,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
page == Page.ALERTS -> AlertsFragment()
page == Page.PROFILES -> ProfilesFragment()
page == Page.RULES -> RulesFragment()
page == Page.APPS -> AppsFragment()
page == Page.LOG_DISPLAY -> LogDisplayFragment()
page == Page.FILTER_LOGGING -> FilterLoggingFragment()
page == Page.FILTER_APPS -> FilterAppsFragment.newInstance()
@@ -351,6 +352,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
ALERTS(R.id.nav_alerts, R.string.page_alerts),
PROFILES(R.id.nav_profiles, R.string.page_profiles),
RULES(R.id.nav_rules, R.string.page_rules),
APPS(R.id.nav_apps, R.string.page_apps),
LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display),
FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
@@ -2,30 +2,14 @@ package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.alerts.AlertRuleDefinition
import se.ajpanton.notificationsmaster.alerts.AlertRuleEditor
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.alerts.AlertTextField
import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher
import se.ajpanton.notificationsmaster.alerts.AlertTextMode
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
import se.ajpanton.notificationsmaster.databinding.FragmentRulesBinding
class RulesFragment : Fragment(R.layout.fragment_rules) {
private data class AppChoice(val name: String, val packageName: String) {
override fun toString() = "$name ($packageName)"
}
private var binding: FragmentRulesBinding? = null
private lateinit var store: AlertConfigurationStore
@@ -58,55 +42,16 @@ class RulesFragment : Fragment(R.layout.fragment_rules) {
return
}
val existing = ruleIds?.let { ids -> configuration.rules.first { it.id in ids } }
val form = LinearLayout(requireContext()).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(24), 0, dp(24), 0) }
val apps = installedApps()
val app = Spinner(requireContext()).apply { adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, apps) }
val profile = Spinner(requireContext()).apply { adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, configuration.profiles.map { it.name }) }
val post = CheckBox(requireContext()).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources }
val update = CheckBox(requireContext()).apply { text = "Edits"; isChecked = existing == null || AlertSource.NOTIFICATION_UPDATE in existing.sources }
val pattern = EditText(requireContext()).apply { hint = "Text to match (optional)"; setText(existing?.matcher?.value) }
val regex = SwitchMaterial(requireContext()).apply { text = "Use regular expression"; isChecked = existing?.matcher?.mode == AlertTextMode.REGEX }
val protected = SwitchMaterial(requireContext()).apply { text = "Play to completion"; isChecked = existing?.playToCompletion == true }
val dnd = SwitchMaterial(requireContext()).apply {
text = "Allow during DND when supported"
isChecked = existing?.allowDuringDnd == true
}
listOf(app, profile, post, update, pattern, regex, protected, dnd).forEach(form::addView)
existing?.let {
app.setSelection(apps.indexOfFirst { value -> value.packageName == it.packageName }.coerceAtLeast(0))
profile.setSelection(configuration.profiles.indexOfFirst { value -> value.id == it.profileId }.coerceAtLeast(0))
}
val dialog = MaterialAlertDialogBuilder(requireContext()).setTitle(if (existing == null) "Add rule" else "Edit rule")
.setView(form).setNegativeButton("Cancel", null).setPositiveButton("Save", null).show()
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
val sources = buildSet {
if (post.isChecked) add(AlertSource.NOTIFICATION_POST)
if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE)
}
if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener }
val text = pattern.text.toString().trim()
val matcher = AlertTextMatcher(
AlertTextField.ANY_TEXT,
if (text.isEmpty()) AlertTextMode.ANY else if (regex.isChecked) AlertTextMode.REGEX else AlertTextMode.CONTAINS,
text,
)
val selectedProfile = configuration.profiles[profile.selectedItemPosition]
val definition = AlertRuleDefinition(sources, matcher, AlertOutcome.PLAY_PROFILE, selectedProfile.id, protected.isChecked, dnd.isChecked)
AlertRuleDialog.show(requireContext(), configuration, existing, if (existing == null) InstalledApps.all(requireContext()) else null) { packageName, definition ->
val updated = if (ruleIds == null) {
AlertRuleEditor.addForApps(configuration, listOf(apps[app.selectedItemPosition].packageName), definition)
AlertRuleEditor.addForApps(configuration, listOfNotNull(packageName), definition)
} else {
AlertRuleEditor.updateConsolidated(configuration, ruleIds, definition)
}
store.save(updated)
dialog.dismiss()
refresh()
}
}
private fun installedApps() = requireContext().packageManager.getInstalledApplications(0).map {
AppChoice(requireContext().packageManager.getApplicationLabel(it).toString(), it.packageName)
}.sortedBy { it.name.lowercase() }
private fun AlertSource.shortName() = if (this == AlertSource.NOTIFICATION_POST) "Appearing" else "Edits"
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
}
@@ -76,6 +76,18 @@ object AlertRuleEditor {
return configuration.copy(rules = configuration.rules + added)
}
fun reorderForApp(
configuration: AlertConfiguration,
packageName: String,
orderedRuleIds: List<String>,
): AlertConfiguration {
require(configuration.rules.filter { it.packageName == packageName }.map { it.id }.toSet() == orderedRuleIds.toSet())
val positions = orderedRuleIds.withIndex().associate { it.value to it.index }
return configuration.copy(rules = configuration.rules.map { rule ->
if (rule.packageName == packageName) rule.copy(order = positions.getValue(rule.id)) else rule
})
}
private fun AlertRule.definition() = AlertRuleDefinition(
sources, matcher, outcome, profileId, playToCompletion, allowDuringDnd, enabled, name,
)