Add notification visibility settings

This commit is contained in:
ajp_anton
2026-08-29 13:21:07 +00:00
parent 4fcbffd4a5
commit 47fbda8a5e
10 changed files with 584 additions and 1 deletions
@@ -0,0 +1,157 @@
package se.ajpanton.notificationsmaster
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import se.ajpanton.notificationsmaster.databinding.FragmentNotificationVisibilityBinding
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
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 var appPolicies = emptyMap<String, AppVisibilityPolicy>()
private var loadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentNotificationVisibilityBinding.bind(view)
store = VisibilityPolicyStore(requireContext())
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = adapter
binding!!.appSearch.doAfterTextChanged { showApps() }
bindEnabled()
loadApps()
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_notification_visibility)
if (binding != null) {
bindEnabled()
showApps()
}
}
override fun onDestroyView() {
loadGeneration++
binding = null
super.onDestroyView()
}
private fun bindEnabled() {
val toggle = binding?.visibilityEnabled ?: return
val policy = store.load()
appPolicies = policy.apps.associateBy(AppVisibilityPolicy::packageName)
toggle.setOnCheckedChangeListener(null)
toggle.isChecked = policy.enabled
toggle.setOnCheckedChangeListener { _, enabled -> store.update { it.copy(enabled = enabled) } }
}
private fun loadApps() {
val currentBinding = binding ?: return
val generation = ++loadGeneration
val context = requireContext().applicationContext
currentBinding.appListLoading.visibility = View.VISIBLE
viewLifecycleOwner.lifecycleScope.launch {
val loaded = withContext(Dispatchers.IO) { InstalledApps.all(context) }
if (binding === currentBinding && generation == loadGeneration) {
apps = loaded
currentBinding.appListLoading.visibility = View.GONE
showApps()
}
}
}
private fun showApps() {
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()
terms.all(searchable::contains)
}.map { app -> AppRow(app, appPolicies[app.packageName]) })
}
private fun openApp(app: InstalledApp) = parentFragmentManager.commit {
replace(R.id.content_frame, AppVisibilityFragment.newInstance(app.packageName))
addToBackStack(null)
}
private data class AppRow(val app: InstalledApp, val policy: AppVisibilityPolicy?)
private class AppAdapter(private val open: (InstalledApp) -> Unit) : RecyclerView.Adapter<AppHolder>() {
private var rows = emptyList<AppRow>()
fun submit(newRows: List<AppRow>) {
rows = newRows
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)
}
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) }
}
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 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)
}