Redesign notification visibility controls

This commit is contained in:
ajp_anton
2026-08-31 01:18:40 +00:00
parent a7c9a2a22f
commit dc11e605d1
8 changed files with 318 additions and 123 deletions
@@ -1,19 +1,23 @@
package se.ajpanton.notificationsmaster
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationsmaster.databinding.FragmentAppVisibilityBinding
import se.ajpanton.notificationsmaster.databinding.ItemVisibilityExceptionBinding
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityExceptionRule
@@ -24,7 +28,8 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
private var binding: FragmentAppVisibilityBinding? = null
private lateinit var store: VisibilityPolicyStore
private lateinit var packageName: String
private val adapter = ExceptionAdapter(::editException, ::deleteException)
private lateinit var adapter: ExceptionAdapter
private lateinit var touchHelper: ItemTouchHelper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAppVisibilityBinding.bind(view)
@@ -32,17 +37,29 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
packageName = requireArguments().getString(ARG_PACKAGE)!!
binding!!.appName.text = InstalledApps.name(requireContext(), packageName)
binding!!.packageName.text = packageName
binding!!.returnToList.setOnClickListener(::returnToList)
val icon = requireContext().packageManager.getApplicationIcon(packageName)
adapter = ExceptionAdapter(icon, ::editException, ::deleteException, ::saveExceptions) {
touchHelper.startDrag(it)
}
binding!!.exceptions.layoutManager = LinearLayoutManager(requireContext())
binding!!.exceptions.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
override fun onMove(list: RecyclerView, from: RecyclerView.ViewHolder, to: RecyclerView.ViewHolder): Boolean {
touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
override fun isLongPressDragEnabled() = false
override fun onMove(
list: RecyclerView,
from: RecyclerView.ViewHolder,
to: RecyclerView.ViewHolder,
): Boolean {
adapter.move(from.bindingAdapterPosition, to.bindingAdapterPosition)
saveApp { copy(exceptions = adapter.rules()) }
saveExceptions(adapter.rules())
return true
}
override fun onSwiped(holder: RecyclerView.ViewHolder, direction: Int) = Unit
}).attachToRecyclerView(binding!!.exceptions)
}).also { it.attachToRecyclerView(binding!!.exceptions) }
binding!!.addException.setOnClickListener { showExceptionDialog(null) }
bind()
}
@@ -57,21 +74,27 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
super.onDestroyView()
}
private fun returnToList(@Suppress("UNUSED_PARAMETER") view: View) {
if (!parentFragmentManager.popBackStackImmediate()) {
parentFragmentManager.commit { replace(R.id.content_frame, NotificationVisibilityFragment()) }
}
}
private fun bind() {
val policy = appPolicy()
bindSurface(binding!!.hideUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy)
bindSurface(binding!!.hideLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy)
bindSurface(binding!!.hideAod, NotificationSurface.AOD, policy)
bindSurface(binding!!.showAod, NotificationSurface.AOD, policy)
bindSurface(binding!!.showLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy)
bindSurface(binding!!.showUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy)
adapter.submit(policy.exceptions)
}
private fun bindSurface(toggle: SwitchMaterial, surface: NotificationSurface, policy: AppVisibilityPolicy) {
toggle.setOnCheckedChangeListener(null)
toggle.isChecked = surface in policy.blockedSurfaces
toggle.setOnCheckedChangeListener { _, hidden ->
toggle.isChecked = surface !in policy.blockedSurfaces
toggle.setOnCheckedChangeListener { _, visible ->
saveApp {
copy(blockedSurfaces = blockedSurfaces.toMutableSet().apply {
if (hidden) add(surface) else remove(surface)
if (visible) remove(surface) else add(surface)
})
}
}
@@ -90,59 +113,46 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
}
}
private fun saveExceptions(rules: List<VisibilityExceptionRule>) {
saveApp { copy(exceptions = rules) }
}
private fun editException(position: Int) = showExceptionDialog(position)
private fun deleteException(position: Int) {
saveApp { copy(exceptions = exceptions.toMutableList().apply { removeAt(position) }) }
bind()
adapter.submit(adapter.rules().toMutableList().apply { removeAt(position) })
saveExceptions(adapter.rules())
}
private fun showExceptionDialog(position: Int?) {
val existing = position?.let { appPolicy().exceptions[it] }
val content = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), 0, dp(24), 0)
}
val existing = position?.let { adapter.rules().getOrNull(it) }
val pattern = EditText(requireContext()).apply {
hint = "Regular expression"
setText(existing?.pattern)
maxLines = 4
}
content.addView(pattern)
val toggles = listOf(
NotificationSurface.UNLOCKED_STATUSBAR to "Hide in unlocked status bar",
NotificationSurface.LOCKSCREEN_COLLAPSED to "Hide on collapsed lockscreen",
NotificationSurface.AOD to "Hide on always-on display",
).associate { (surface, label) ->
surface to SwitchMaterial(requireContext()).apply {
text = label
isChecked = surface in existing?.blockedSurfaces.orEmpty()
content.addView(this)
}
setPadding(dp(24), 0, dp(24), 0)
}
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(if (existing == null) "Add text exception" else "Edit text exception")
.setView(content)
.setView(pattern)
.setNegativeButton("Cancel", null)
.setPositiveButton("Save", null)
.create()
dialog.setOnShowListener {
dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val rule = runCatching {
VisibilityExceptionRule(
val rule = VisibilityExceptionRule(
pattern.text.toString(),
toggles.filterValues { it.isChecked }.keys,
).also { require(it.hasValidPattern()) }
}.getOrElse {
existing?.blockedSurfaces ?: appPolicy().blockedSurfaces,
)
if (!rule.hasValidPattern()) {
pattern.error = "Enter a valid, non-empty regular expression"
return@setOnClickListener
}
saveApp {
copy(exceptions = exceptions.toMutableList().apply {
val rules = adapter.rules().toMutableList().apply {
if (position == null) add(rule) else set(position, rule)
})
}
bind()
adapter.submit(rules)
saveExceptions(rules)
dialog.dismiss()
}
}
@@ -152,8 +162,11 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
private class ExceptionAdapter(
private val appIcon: Drawable,
private val edit: (Int) -> Unit,
private val delete: (Int) -> Unit,
private val changed: (List<VisibilityExceptionRule>) -> Unit,
private val startDrag: (ExceptionHolder) -> Unit,
) : RecyclerView.Adapter<ExceptionHolder>() {
private val items = mutableListOf<VisibilityExceptionRule>()
@@ -170,74 +183,72 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
fun rules() = items.toList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExceptionHolder {
val row = LinearLayout(parent.context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
setPadding(0, dp(parent, 6), 0, dp(parent, 6))
isClickable = true
isFocusable = true
background = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
.let { values -> values.getDrawable(0).also { values.recycle() } }
}
val handle = TextView(parent.context).apply {
text = ""
textSize = 22f
gravity = android.view.Gravity.CENTER
contentDescription = "Drag to reorder"
layoutParams = LinearLayout.LayoutParams(dp(parent, 40), dp(parent, 48))
}
val textColumn = LinearLayout(parent.context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val pattern = TextView(parent.context).apply { textSize = 15f }
val summary = TextView(parent.context).apply { textSize = 12f }
textColumn.addView(pattern)
textColumn.addView(summary)
val remove = Button(parent.context).apply {
text = "Delete"
isAllCaps = false
}
row.addView(handle)
row.addView(textColumn)
row.addView(remove)
return ExceptionHolder(row, pattern, summary, remove)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ExceptionHolder(
ItemVisibilityExceptionBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: ExceptionHolder, position: Int) {
val binding = holder.binding
val rule = items[position]
holder.pattern.text = rule.pattern
holder.summary.text = surfaceSummary(rule.blockedSurfaces)
holder.itemView.setOnClickListener {
holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }?.let(edit)
binding.pattern.text = rule.pattern
bindSurface(binding.aodIcon, binding.aodCross, rule, NotificationSurface.AOD)
bindSurface(
binding.lockscreenIcon,
binding.lockscreenCross,
rule,
NotificationSurface.LOCKSCREEN_COLLAPSED,
)
bindSurface(
binding.unlockedIcon,
binding.unlockedCross,
rule,
NotificationSurface.UNLOCKED_STATUSBAR,
)
binding.aodCell.setOnClickListener { toggle(holder, NotificationSurface.AOD) }
binding.lockscreenCell.setOnClickListener {
toggle(holder, NotificationSurface.LOCKSCREEN_COLLAPSED)
}
holder.remove.setOnClickListener {
holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }?.let(delete)
binding.unlockedCell.setOnClickListener {
toggle(holder, NotificationSurface.UNLOCKED_STATUSBAR)
}
binding.edit.setOnClickListener { holder.positionOrNull()?.let(edit) }
binding.delete.setOnClickListener { holder.positionOrNull()?.let(delete) }
binding.dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) startDrag(holder)
false
}
}
override fun getItemCount() = items.size
private fun surfaceSummary(surfaces: Set<NotificationSurface>): String {
val names = listOf(
NotificationSurface.UNLOCKED_STATUSBAR to "status bar",
NotificationSurface.LOCKSCREEN_COLLAPSED to "lockscreen",
NotificationSurface.AOD to "AOD",
).filter { it.first in surfaces }.joinToString { it.second }
return if (names.isEmpty()) "Show on all surfaces" else "Hide: $names"
private fun toggle(holder: ExceptionHolder, surface: NotificationSurface) {
val position = holder.positionOrNull() ?: return
val current = items[position]
items[position] = current.copy(blockedSurfaces = current.blockedSurfaces.toMutableSet().apply {
if (surface in current.blockedSurfaces) remove(surface) else add(surface)
})
notifyItemChanged(position)
changed(rules())
}
private fun dp(parent: ViewGroup, value: Int) =
(value * parent.resources.displayMetrics.density).toInt()
private fun bindSurface(
icon: ImageView,
cross: TextView,
rule: VisibilityExceptionRule,
surface: NotificationSurface,
) {
icon.setImageDrawable(appIcon.constantState?.newDrawable()?.mutate() ?: appIcon)
val hidden = surface in rule.blockedSurfaces
icon.alpha = if (hidden) 0.25f else 1f
cross.visibility = if (hidden) View.VISIBLE else View.GONE
}
private class ExceptionHolder(
view: View,
val pattern: TextView,
val summary: TextView,
val remove: Button,
) : RecyclerView.ViewHolder(view)
private fun ExceptionHolder.positionOrNull() =
bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION }
}
private class ExceptionHolder(val binding: ItemVisibilityExceptionBinding) :
RecyclerView.ViewHolder(binding.root)
companion object {
private const val ARG_PACKAGE = "package"
@@ -78,7 +78,15 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
})
currentItemId = savedInstanceState?.getInt(STATE_CURRENT_ITEM_ID) ?: R.id.nav_view_logs
if (savedInstanceState == null) {
navigateTo(currentItemId)
} else {
val page = Page.fromMenuItem(currentItemId)
refreshLogProtection(requestUnlock = hasWindowFocus())
syncDrawerSelection(currentItemId)
title = getString(page.titleRes)
binding.root.post(::applyPageTitleVisibility)
}
applyNavigationDrawerInsets()
syncDrawerToggle()
}
@@ -156,6 +164,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
refreshLogProtection(requestUnlock = hasWindowFocus())
syncDrawerSelection(itemId)
val page = Page.fromMenuItem(itemId)
supportFragmentManager.popBackStackImmediate(
null,
androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE,
)
supportFragmentManager.commit {
replace(
R.id.content_frame,
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,6L18,18M18,6L6,18"
android:strokeColor="@color/visibility_hidden_cross"
android:strokeLineCap="round"
android:strokeWidth="2.5" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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="M7,4h3v3H7zM14,4h3v3h-3zM7,10.5h3v3H7zM14,10.5h3v3h-3zM7,17h3v3H7zM14,17h3v3h-3z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34a0.996,0.996 0,0 0,-1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>
@@ -5,10 +5,18 @@
android:orientation="vertical"
android:padding="@dimen/page_padding">
<com.google.android.material.button.MaterialButton
android:id="@+id/return_to_list"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Return to list" />
<TextView
android:id="@+id/app_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceHeadline6"
android:textStyle="bold" />
@@ -22,52 +30,45 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Hide by default"
android:textStyle="bold" />
android:text="Default visibility"
android:textAppearance="?attr/textAppearanceHeadline6" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/hide_aod"
android:id="@+id/show_aod"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Always-on display" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/hide_lockscreen"
android:id="@+id/show_lockscreen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Collapsed lockscreen" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/hide_unlocked"
android:id="@+id/show_unlocked"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Unlocked status bar" />
<LinearLayout
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center_vertical"
android:orientation="horizontal">
android:text="Exceptions based on text contents"
android:textAppearance="?attr/textAppearanceHeadline6" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Ordered text exceptions"
android:textStyle="bold" />
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/add_exception"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Add" />
</LinearLayout>
android:layout_marginTop="4dp"
android:text="Add exception" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="The first matching expression replaces the defaults above. Drag to reorder."
android:text="The first matching expression stops evaluating and replaces default visibility. Drag to reorder."
android:textAppearance="?attr/textAppearanceBody2" />
<androidx.recyclerview.widget.RecyclerView
@@ -22,7 +22,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Tap an app icon to hide or show it on AOD, the lockscreen, or the unlocked status bar. Tap the app name for text exceptions."
android:text="Icons represent AOD, Lockscreen and Unlocked, respectively. Tap to toggle visibility.&#10;Tap the app for more advanced options."
android:textAppearance="?attr/textAppearanceCaption" />
<EditText
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingTop="6dp"
android:paddingBottom="6dp">
<ImageView
android:id="@+id/drag_handle"
android:layout_width="40dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Drag to reorder"
android:padding="8dp"
android:src="@drawable/ic_drag_dots"
app:tint="?android:attr/textColorSecondary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/pattern"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?attr/textAppearanceBody1" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="40dp"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/aod_cell"
android:layout_width="34dp"
android:layout_height="40dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/aod_icon"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:contentDescription="AOD visibility"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/aod_cross"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="✕"
android:textColor="@color/visibility_hidden_cross"
android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/lockscreen_cell"
android:layout_width="34dp"
android:layout_height="40dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/lockscreen_icon"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:contentDescription="Lockscreen visibility"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/lockscreen_cross"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="✕"
android:textColor="@color/visibility_hidden_cross"
android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/unlocked_cell"
android:layout_width="34dp"
android:layout_height="40dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/unlocked_icon"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:contentDescription="Unlocked status-bar visibility"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/unlocked_cross"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="✕"
android:textColor="@color/visibility_hidden_cross"
android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
</LinearLayout>
<ImageButton
android:id="@+id/edit"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Edit expression"
android:padding="12dp"
android:src="@drawable/ic_edit"
app:tint="?android:attr/textColorPrimary" />
<ImageButton
android:id="@+id/delete"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Delete expression"
android:padding="12dp"
android:src="@drawable/ic_delete_cross" />
</LinearLayout>