From ce49f21739a82bdbb40ec90d8e5ae3e819eaa755 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 18 Aug 2026 01:52:31 +0000 Subject: [PATCH] Add per-app alert rule management --- .../notificationsmaster/AlertRuleDialog.kt | 80 +++++++++++++++++ .../notificationsmaster/AppRulesFragment.kt | 89 +++++++++++++++++++ .../notificationsmaster/AppsFragment.kt | 46 ++++++++++ .../notificationsmaster/InstalledApps.kt | 17 ++++ .../notificationsmaster/MainActivity.kt | 2 + .../notificationsmaster/RulesFragment.kt | 59 +----------- .../alerts/AlertRuleEditor.kt | 12 +++ .../main/res/layout/fragment_app_rules.xml | 29 ++++++ app/src/main/res/layout/fragment_apps.xml | 25 ++++++ app/src/main/res/menu/drawer_menu.xml | 3 + app/src/main/res/values/strings.xml | 2 + .../alerts/AlertRuleEditorTest.kt | 11 +++ 12 files changed, 318 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/InstalledApps.kt create mode 100644 app/src/main/res/layout/fragment_app_rules.xml create mode 100644 app/src/main/res/layout/fragment_apps.xml diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt new file mode 100644 index 0000000..8413a88 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AlertRuleDialog.kt @@ -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? = 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() +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt new file mode 100644 index 0000000..b13d2a2 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AppRulesFragment.kt @@ -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() { + var items = emptyList(); private set + fun submit(rules: List) { 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) } + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt new file mode 100644 index 0000000..f5b70d1 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/AppsFragment.kt @@ -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) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/InstalledApps.kt b/app/src/main/java/se/ajpanton/notificationsmaster/InstalledApps.kt new file mode 100644 index 0000000..44d5ced --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/InstalledApps.kt @@ -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 = 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) +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt index f869fe8..6716c0c 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt @@ -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), diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt index d45f231..754d4e5 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt @@ -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() } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt index bb9f5bc..a9e2ab7 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/alerts/AlertRuleEditor.kt @@ -76,6 +76,18 @@ object AlertRuleEditor { return configuration.copy(rules = configuration.rules + added) } + fun reorderForApp( + configuration: AlertConfiguration, + packageName: String, + orderedRuleIds: List, + ): 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, ) diff --git a/app/src/main/res/layout/fragment_app_rules.xml b/app/src/main/res/layout/fragment_app_rules.xml new file mode 100644 index 0000000..64fc864 --- /dev/null +++ b/app/src/main/res/layout/fragment_app_rules.xml @@ -0,0 +1,29 @@ + + + +