Reorganize notification visibility settings

This commit is contained in:
ajp_anton
2026-08-30 01:40:16 +00:00
parent e814a1aab3
commit 7331ac8f01
16 changed files with 496 additions and 211 deletions
@@ -1,10 +1,14 @@
package se.ajpanton.notificationsmaster
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
@@ -14,7 +18,9 @@ import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import se.ajpanton.notificationsmaster.capture.SeenApps
import se.ajpanton.notificationsmaster.databinding.FragmentNotificationVisibilityBinding
import se.ajpanton.notificationsmaster.databinding.ItemNotificationVisibilityAppBinding
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
@@ -22,8 +28,8 @@ import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_visibility) {
private var binding: FragmentNotificationVisibilityBinding? = null
private lateinit var store: VisibilityPolicyStore
private val adapter = AppAdapter(::openApp)
private var apps = emptyList<InstalledApp>()
private val adapter = AppAdapter(::openApp, ::toggleSurface)
private var apps = emptyList<AppSnapshot>()
private var appPolicies = emptyMap<String, AppVisibilityPolicy>()
private var loadGeneration = 0
@@ -32,6 +38,7 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
store = VisibilityPolicyStore(requireContext())
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = adapter
binding!!.appListRefresh.setOnRefreshListener(::loadApps)
binding!!.appSearch.doAfterTextChanged { showApps() }
bindEnabled()
loadApps()
@@ -65,12 +72,30 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
val currentBinding = binding ?: return
val generation = ++loadGeneration
val context = requireContext().applicationContext
val policies = appPolicies
currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true
viewLifecycleOwner.lifecycleScope.launch {
val loaded = withContext(Dispatchers.IO) { InstalledApps.all(context) }
val loaded = withContext(Dispatchers.IO) {
val manager = context.packageManager
SeenApps.snapshot(context).mapNotNull { packageName ->
runCatching {
val info = manager.getApplicationInfo(packageName, 0)
AppSnapshot(
InstalledApp(manager.getApplicationLabel(info).toString(), packageName),
manager.getApplicationIcon(info),
)
}.getOrNull()
}.sortedWith(
compareBy<AppSnapshot> { policies[it.app.packageName]?.isEdited() != true }
.thenBy { it.app.name.lowercase() }
.thenBy { it.app.packageName },
)
}
if (binding === currentBinding && generation == loadGeneration) {
apps = loaded
currentBinding.appListLoading.visibility = View.GONE
currentBinding.appListRefresh.isRefreshing = false
showApps()
}
}
@@ -80,10 +105,29 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
val currentBinding = binding ?: return
val terms = currentBinding.appSearch.text.toString().lowercase().trim()
.split(Regex("\\s+")).filter(String::isNotEmpty)
adapter.submit(apps.filter { app ->
val searchable = "${app.name}\n${app.packageName}".lowercase()
val rows = apps.filter { app ->
val searchable = "${app.app.name}\n${app.app.packageName}".lowercase()
terms.all(searchable::contains)
}.map { app -> AppRow(app, appPolicies[app.packageName]) })
}.map { app -> AppRow(app, appPolicies[app.app.packageName]) }
adapter.submit(rows)
currentBinding.appListEmpty.visibility = if (
rows.isEmpty() && currentBinding.appListLoading.visibility != View.VISIBLE
) View.VISIBLE else View.GONE
}
private fun toggleSurface(row: AppRow, surface: NotificationSurface) {
val saved = store.update { policy ->
val current = policy.apps.firstOrNull { it.packageName == row.app.app.packageName }
?: AppVisibilityPolicy(row.app.app.packageName)
val updated = current.copy(blockedSurfaces = current.blockedSurfaces.toMutableSet().apply {
if (surface in current.blockedSurfaces) remove(surface) else add(surface)
})
val apps = policy.apps.filterNot { it.packageName == current.packageName }.toMutableList()
if (updated.isEdited()) apps += updated
policy.copy(apps = apps.sortedBy(AppVisibilityPolicy::packageName))
}
appPolicies = saved.apps.associateBy(AppVisibilityPolicy::packageName)
adapter.updatePolicy(row.app.app.packageName, appPolicies[row.app.app.packageName])
}
private fun openApp(app: InstalledApp) = parentFragmentManager.commit {
@@ -91,9 +135,13 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
addToBackStack(null)
}
private data class AppRow(val app: InstalledApp, val policy: AppVisibilityPolicy?)
private data class AppSnapshot(val app: InstalledApp, val icon: Drawable)
private data class AppRow(val app: AppSnapshot, val policy: AppVisibilityPolicy?)
private class AppAdapter(private val open: (InstalledApp) -> Unit) : RecyclerView.Adapter<AppHolder>() {
private class AppAdapter(
private val open: (InstalledApp) -> Unit,
private val toggle: (AppRow, NotificationSurface) -> Unit,
) : RecyclerView.Adapter<AppHolder>() {
private var rows = emptyList<AppRow>()
fun submit(newRows: List<AppRow>) {
@@ -101,57 +149,74 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppHolder {
val row = LinearLayout(parent.context).apply {
orientation = LinearLayout.VERTICAL
isClickable = true
isFocusable = true
setPadding(0, dp(parent, 8), 0, dp(parent, 8))
background = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
.let { values -> values.getDrawable(0).also { values.recycle() } }
}
val name = TextView(parent.context).apply {
textSize = 16f
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
val packageName = TextView(parent.context).apply { textSize = 12f }
val summary = TextView(parent.context).apply { textSize = 13f }
row.addView(name)
row.addView(packageName)
row.addView(summary)
return AppHolder(row, name, packageName, summary)
fun updatePolicy(packageName: String, policy: AppVisibilityPolicy?) {
val position = rows.indexOfFirst { it.app.app.packageName == packageName }
if (position < 0) return
rows = rows.toMutableList().also { it[position] = it[position].copy(policy = policy) }
notifyItemChanged(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AppHolder(
ItemNotificationVisibilityAppBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: AppHolder, position: Int) {
val row = rows[position]
holder.name.text = row.app.name
holder.packageName.text = row.app.packageName
holder.summary.text = summary(row.policy)
holder.itemView.setOnClickListener { open(row.app) }
val binding = holder.binding
val policy = row.policy
binding.visibilityAppName.text = row.app.app.name
binding.visibilityAppPackage.text = row.app.app.packageName
val exceptionCount = policy?.exceptions?.size ?: 0
binding.visibilityAppExceptions.visibility = if (exceptionCount > 0) View.VISIBLE else View.GONE
binding.visibilityAppExceptions.text = "$exceptionCount text exception" + if (exceptionCount == 1) "" else "s"
binding.visibilityAppRoot.setBackgroundColor(
if (policy?.isEdited() == true) ContextCompat.getColor(
binding.root.context,
R.color.visibility_edited_background,
) else Color.TRANSPARENT,
)
bindSurface(binding.visibilityAodIcon, binding.visibilityAodCross, row, NotificationSurface.AOD)
bindSurface(
binding.visibilityLockscreenIcon,
binding.visibilityLockscreenCross,
row,
NotificationSurface.LOCKSCREEN_COLLAPSED,
)
bindSurface(
binding.visibilityUnlockedIcon,
binding.visibilityUnlockedCross,
row,
NotificationSurface.UNLOCKED_STATUSBAR,
)
binding.visibilityAodCell.setOnClickListener { toggle(row, NotificationSurface.AOD) }
binding.visibilityLockscreenCell.setOnClickListener {
toggle(row, NotificationSurface.LOCKSCREEN_COLLAPSED)
}
binding.visibilityUnlockedCell.setOnClickListener {
toggle(row, NotificationSurface.UNLOCKED_STATUSBAR)
}
binding.visibilityAppRoot.setOnClickListener { open(row.app.app) }
}
override fun getItemCount() = rows.size
private fun summary(policy: AppVisibilityPolicy?): String {
if (policy == null) return "Visible on all surfaces"
val hidden = listOf(
NotificationSurface.UNLOCKED_STATUSBAR to "status bar",
NotificationSurface.LOCKSCREEN_COLLAPSED to "lockscreen",
NotificationSurface.AOD to "AOD",
).filter { it.first in policy.blockedSurfaces }.joinToString { it.second }
val defaults = if (hidden.isEmpty()) "Visible on all surfaces" else "Hidden: $hidden"
return if (policy.exceptions.isEmpty()) defaults else "$defaults${policy.exceptions.size} text exception" +
if (policy.exceptions.size == 1) "" else "s"
private fun bindSurface(
icon: ImageView,
cross: TextView,
row: AppRow,
surface: NotificationSurface,
) {
icon.setImageDrawable(row.app.icon.constantState?.newDrawable()?.mutate() ?: row.app.icon)
val hidden = surface in row.policy?.blockedSurfaces.orEmpty()
icon.alpha = if (hidden) 0.25f else 1f
cross.visibility = if (hidden) View.VISIBLE else View.GONE
}
private fun dp(parent: ViewGroup, value: Int) =
(value * parent.resources.displayMetrics.density).toInt()
}
private class AppHolder(
view: View,
val name: TextView,
val packageName: TextView,
val summary: TextView,
) : RecyclerView.ViewHolder(view)
private class AppHolder(val binding: ItemNotificationVisibilityAppBinding) :
RecyclerView.ViewHolder(binding.root)
private companion object {
fun AppVisibilityPolicy.isEdited() = blockedSurfaces.isNotEmpty() || exceptions.isNotEmpty()
}
}