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,55 +1,21 @@
package se.ajpanton.notificationsmaster package se.ajpanton.notificationsmaster
import android.Manifest
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.View import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.databinding.FragmentAlertStatusBinding import se.ajpanton.notificationsmaster.databinding.FragmentAlertStatusBinding
import se.ajpanton.notificationsmaster.debug.DebugNotifications
class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) { class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) {
private var binding: FragmentAlertStatusBinding? = null private var binding: FragmentAlertStatusBinding? = null
private var pendingCount: Int? = null
private var pendingCycle = false
private var updatingCycle = false
private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val context = context ?: return@registerForActivityResult
if (granted) {
pendingCount?.let { DebugNotifications.setCount(context, it) }
if (pendingCycle) DebugNotifications.setCycling(context, true)
refreshDebugControls()
} else {
Toast.makeText(context, R.string.debug_notification_permission_required, Toast.LENGTH_SHORT).show()
refreshDebugControls()
}
pendingCount = null
pendingCycle = false
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAlertStatusBinding.bind(view) binding = FragmentAlertStatusBinding.bind(view)
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
binding!!.debugMinus10.setOnClickListener { changeDebugCount(-10) }
binding!!.debugMinus1.setOnClickListener { changeDebugCount(-1) }
binding!!.debugPlus1.setOnClickListener { changeDebugCount(1) }
binding!!.debugPlus10.setOnClickListener { changeDebugCount(10) }
binding!!.debugReset.setOnClickListener { setDebugCount(0) }
binding!!.debugCycle.setOnCheckedChangeListener { _, enabled ->
if (updatingCycle) return@setOnCheckedChangeListener
if (enabled && !DebugNotifications.hasPermission(requireContext())) {
pendingCycle = true
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
DebugNotifications.setCycling(requireContext(), enabled)
}
}
} }
override fun onResume() { override fun onResume() {
@@ -66,31 +32,6 @@ class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) {
append(if (config.rules.count { it.enabled } == 1) "." else "s.") append(if (config.rules.count { it.enabled } == 1) "." else "s.")
} }
binding?.notificationAccess?.text = if (access) "Notification access enabled" else "Enable notification access" binding?.notificationAccess?.text = if (access) "Notification access enabled" else "Enable notification access"
DebugNotifications.apply(context)
refreshDebugControls()
}
private fun changeDebugCount(delta: Int) = setDebugCount(DebugNotifications.count(requireContext()) + delta)
private fun setDebugCount(count: Int) {
val desired = count.coerceIn(0, DebugNotifications.MAX_COUNT)
if (desired > 0 && !DebugNotifications.hasPermission(requireContext())) {
pendingCount = desired
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
DebugNotifications.setCount(requireContext(), desired)
refreshDebugControls()
}
private fun refreshDebugControls() {
val context = context ?: return
val count = DebugNotifications.count(context)
binding?.debugCount?.text = count.toString()
binding?.debugCycle?.isEnabled = count > 0
updatingCycle = true
binding?.debugCycle?.isChecked = DebugNotifications.isCycling(context)
updatingCycle = false
} }
override fun onDestroyView() { binding = null; super.onDestroyView() } override fun onDestroyView() { binding = null; super.onDestroyView() }
@@ -0,0 +1,82 @@
package se.ajpanton.notificationsmaster
import android.Manifest
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import se.ajpanton.notificationsmaster.databinding.FragmentDebugNotificationsBinding
import se.ajpanton.notificationsmaster.debug.DebugNotifications
class DebugNotificationsFragment : Fragment(R.layout.fragment_debug_notifications) {
private var binding: FragmentDebugNotificationsBinding? = null
private var pendingCount: Int? = null
private var pendingCycle = false
private var updatingCycle = false
private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val context = context ?: return@registerForActivityResult
if (granted) {
pendingCount?.let { DebugNotifications.setCount(context, it) }
if (pendingCycle) DebugNotifications.setCycling(context, true)
} else {
Toast.makeText(context, R.string.debug_notification_permission_required, Toast.LENGTH_SHORT).show()
}
pendingCount = null
pendingCycle = false
refresh()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentDebugNotificationsBinding.bind(view)
binding!!.debugMinus10.setOnClickListener { changeCount(-10) }
binding!!.debugMinus1.setOnClickListener { changeCount(-1) }
binding!!.debugPlus1.setOnClickListener { changeCount(1) }
binding!!.debugPlus10.setOnClickListener { changeCount(10) }
binding!!.debugReset.setOnClickListener { setCount(0) }
binding!!.debugCycle.setOnCheckedChangeListener { _, enabled ->
if (updatingCycle) return@setOnCheckedChangeListener
if (enabled && !DebugNotifications.hasPermission(requireContext())) {
pendingCycle = true
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
DebugNotifications.setCycling(requireContext(), enabled)
}
}
}
override fun onResume() {
super.onResume()
activity?.title = getString(R.string.page_debug_notifications)
DebugNotifications.apply(requireContext())
refresh()
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun changeCount(delta: Int) = setCount(DebugNotifications.count(requireContext()) + delta)
private fun setCount(count: Int) {
val desired = count.coerceIn(0, DebugNotifications.MAX_COUNT)
if (desired > 0 && !DebugNotifications.hasPermission(requireContext())) {
pendingCount = desired
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
DebugNotifications.setCount(requireContext(), desired)
refresh()
}
private fun refresh() {
val context = context ?: return
val count = DebugNotifications.count(context)
binding?.debugCount?.text = count.toString()
binding?.debugCycle?.isEnabled = count > 0
updatingCycle = true
binding?.debugCycle?.isChecked = DebugNotifications.isCycling(context)
updatingCycle = false
}
}
@@ -123,7 +123,7 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
currentBinding.appListLoading.visibility = View.VISIBLE currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true currentBinding.appListRefresh.isRefreshing = true
val rule = store.load() val rule = store.load()
val seen = SeenApps.snapshot() val seen = SeenApps.snapshot(requireContext())
val context = requireContext().applicationContext val context = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val apps = withContext(Dispatchers.IO) { val apps = withContext(Dispatchers.IO) {
@@ -169,6 +169,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
page == Page.APPS -> AppsFragment() page == Page.APPS -> AppsFragment()
page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment() page == Page.NOTIFICATION_VISIBILITY -> NotificationVisibilityFragment()
page == Page.CARDS_LAYOUT -> CardsLayoutFragment() page == Page.CARDS_LAYOUT -> CardsLayoutFragment()
page == Page.DEBUG_NOTIFICATIONS -> DebugNotificationsFragment()
page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment()
page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment()
page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance()
@@ -375,6 +376,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
APPS(R.id.nav_apps, R.string.page_apps), APPS(R.id.nav_apps, R.string.page_apps),
NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility), NOTIFICATION_VISIBILITY(R.id.nav_notification_visibility, R.string.page_notification_visibility),
CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout), CARDS_LAYOUT(R.id.nav_cards_layout, R.string.page_cards_layout),
DEBUG_NOTIFICATIONS(R.id.nav_debug_notifications, R.string.page_debug_notifications),
LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display), 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_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
@@ -1,10 +1,14 @@
package se.ajpanton.notificationsmaster package se.ajpanton.notificationsmaster
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.commit import androidx.fragment.app.commit
@@ -14,7 +18,9 @@ import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import se.ajpanton.notificationsmaster.capture.SeenApps
import se.ajpanton.notificationsmaster.databinding.FragmentNotificationVisibilityBinding import se.ajpanton.notificationsmaster.databinding.FragmentNotificationVisibilityBinding
import se.ajpanton.notificationsmaster.databinding.ItemNotificationVisibilityAppBinding
import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy import se.ajpanton.notificationsmaster.visibility.AppVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
@@ -22,8 +28,8 @@ import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_visibility) { class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_visibility) {
private var binding: FragmentNotificationVisibilityBinding? = null private var binding: FragmentNotificationVisibilityBinding? = null
private lateinit var store: VisibilityPolicyStore private lateinit var store: VisibilityPolicyStore
private val adapter = AppAdapter(::openApp) private val adapter = AppAdapter(::openApp, ::toggleSurface)
private var apps = emptyList<InstalledApp>() private var apps = emptyList<AppSnapshot>()
private var appPolicies = emptyMap<String, AppVisibilityPolicy>() private var appPolicies = emptyMap<String, AppVisibilityPolicy>()
private var loadGeneration = 0 private var loadGeneration = 0
@@ -32,6 +38,7 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
store = VisibilityPolicyStore(requireContext()) store = VisibilityPolicyStore(requireContext())
binding!!.appList.layoutManager = LinearLayoutManager(requireContext()) binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = adapter binding!!.appList.adapter = adapter
binding!!.appListRefresh.setOnRefreshListener(::loadApps)
binding!!.appSearch.doAfterTextChanged { showApps() } binding!!.appSearch.doAfterTextChanged { showApps() }
bindEnabled() bindEnabled()
loadApps() loadApps()
@@ -65,12 +72,30 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
val currentBinding = binding ?: return val currentBinding = binding ?: return
val generation = ++loadGeneration val generation = ++loadGeneration
val context = requireContext().applicationContext val context = requireContext().applicationContext
val policies = appPolicies
currentBinding.appListLoading.visibility = View.VISIBLE currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true
viewLifecycleOwner.lifecycleScope.launch { 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) { if (binding === currentBinding && generation == loadGeneration) {
apps = loaded apps = loaded
currentBinding.appListLoading.visibility = View.GONE currentBinding.appListLoading.visibility = View.GONE
currentBinding.appListRefresh.isRefreshing = false
showApps() showApps()
} }
} }
@@ -80,10 +105,29 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
val currentBinding = binding ?: return val currentBinding = binding ?: return
val terms = currentBinding.appSearch.text.toString().lowercase().trim() val terms = currentBinding.appSearch.text.toString().lowercase().trim()
.split(Regex("\\s+")).filter(String::isNotEmpty) .split(Regex("\\s+")).filter(String::isNotEmpty)
adapter.submit(apps.filter { app -> val rows = apps.filter { app ->
val searchable = "${app.name}\n${app.packageName}".lowercase() val searchable = "${app.app.name}\n${app.app.packageName}".lowercase()
terms.all(searchable::contains) 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 { private fun openApp(app: InstalledApp) = parentFragmentManager.commit {
@@ -91,9 +135,13 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
addToBackStack(null) 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>() private var rows = emptyList<AppRow>()
fun submit(newRows: List<AppRow>) { fun submit(newRows: List<AppRow>) {
@@ -101,57 +149,74 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppHolder { fun updatePolicy(packageName: String, policy: AppVisibilityPolicy?) {
val row = LinearLayout(parent.context).apply { val position = rows.indexOfFirst { it.app.app.packageName == packageName }
orientation = LinearLayout.VERTICAL if (position < 0) return
isClickable = true rows = rows.toMutableList().also { it[position] = it[position].copy(policy = policy) }
isFocusable = true notifyItemChanged(position)
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 onCreateViewHolder(parent: ViewGroup, viewType: Int) = AppHolder(
ItemNotificationVisibilityAppBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: AppHolder, position: Int) { override fun onBindViewHolder(holder: AppHolder, position: Int) {
val row = rows[position] val row = rows[position]
holder.name.text = row.app.name val binding = holder.binding
holder.packageName.text = row.app.packageName val policy = row.policy
holder.summary.text = summary(row.policy) binding.visibilityAppName.text = row.app.app.name
holder.itemView.setOnClickListener { open(row.app) } 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 override fun getItemCount() = rows.size
private fun summary(policy: AppVisibilityPolicy?): String { private fun bindSurface(
if (policy == null) return "Visible on all surfaces" icon: ImageView,
val hidden = listOf( cross: TextView,
NotificationSurface.UNLOCKED_STATUSBAR to "status bar", row: AppRow,
NotificationSurface.LOCKSCREEN_COLLAPSED to "lockscreen", surface: NotificationSurface,
NotificationSurface.AOD to "AOD", ) {
).filter { it.first in policy.blockedSurfaces }.joinToString { it.second } icon.setImageDrawable(row.app.icon.constantState?.newDrawable()?.mutate() ?: row.app.icon)
val defaults = if (hidden.isEmpty()) "Visible on all surfaces" else "Hidden: $hidden" val hidden = surface in row.policy?.blockedSurfaces.orEmpty()
return if (policy.exceptions.isEmpty()) defaults else "$defaults${policy.exceptions.size} text exception" + icon.alpha = if (hidden) 0.25f else 1f
if (policy.exceptions.size == 1) "" else "s" cross.visibility = if (hidden) View.VISIBLE else View.GONE
}
} }
private fun dp(parent: ViewGroup, value: Int) = private class AppHolder(val binding: ItemNotificationVisibilityAppBinding) :
(value * parent.resources.displayMetrics.density).toInt() RecyclerView.ViewHolder(binding.root)
}
private class AppHolder( private companion object {
view: View, fun AppVisibilityPolicy.isEdited() = blockedSurfaces.isNotEmpty() || exceptions.isNotEmpty()
val name: TextView, }
val packageName: TextView,
val summary: TextView,
) : RecyclerView.ViewHolder(view)
} }
@@ -62,7 +62,7 @@ class NotificationCaptureService : NotificationListenerService() {
getActiveNotifications()?.forEach { sbn -> getActiveNotifications()?.forEach { sbn ->
if (DebugNotifications.isManaged(this, sbn)) return@forEach if (DebugNotifications.isManaged(this, sbn)) return@forEach
val snapshot = NotificationContents.snapshot(sbn) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(this, snapshot.packageName)
activeNotifications[snapshot.key] = snapshot activeNotifications[snapshot.key] = snapshot
record( record(
snapshot, snapshot,
@@ -77,7 +77,7 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
if (DebugNotifications.isManaged(this, sbn)) return if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = NotificationContents.snapshot(sbn) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(this, snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot) val previous = activeNotifications.put(snapshot.key, snapshot)
when { when {
previous == null -> { previous == null -> {
@@ -113,7 +113,7 @@ class NotificationCaptureService : NotificationListenerService() {
) { ) {
if (DebugNotifications.isManaged(this, sbn)) return if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn) val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(this, snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false) record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
} }
@@ -1,10 +1,39 @@
package se.ajpanton.notificationsmaster.capture package se.ajpanton.notificationsmaster.capture
import java.util.concurrent.ConcurrentHashMap import android.content.Context
import android.os.SystemClock
import kotlin.math.abs
/** Process/boot-lifetime record of packages seen by the listener. */ /** Persists packages seen by the listener for the duration of the current boot. */
object SeenApps { object SeenApps {
private val packages = ConcurrentHashMap.newKeySet<String>() @Synchronized
fun markSeen(packageName: String) { packages += packageName } fun markSeen(context: Context, packageName: String) {
fun snapshot(): Set<String> = packages.toSet() val preferences = preferences(context)
resetAfterBoot(preferences)
val packages = preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty()
if (packageName !in packages) {
preferences.edit().putStringSet(KEY_PACKAGES, packages + packageName).apply()
}
}
@Synchronized
fun snapshot(context: Context): Set<String> {
val preferences = preferences(context)
resetAfterBoot(preferences)
return preferences.getStringSet(KEY_PACKAGES, emptySet()).orEmpty().toSet()
}
private fun resetAfterBoot(preferences: android.content.SharedPreferences) {
val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
if (abs(preferences.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
preferences.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).remove(KEY_PACKAGES).commit()
}
}
private fun preferences(context: Context) =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private const val PREFERENCES = "seen_notification_apps"
private const val KEY_PACKAGES = "packages"
private const val KEY_BOOT_EPOCH = "boot_epoch"
} }
@@ -28,80 +28,5 @@
android:text="Without optional system integration, Android keeps the source apps own sound and vibration. Matching custom alerts play in addition; they do not replace it." android:text="Without optional system integration, Android keeps the source apps own sound and vibration. Matching custom alerts play in addition; they do not replace it."
android:textSize="14sp" /> android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/debug_notifications_title"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_notifications_description"
android:textSize="14sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_cycle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/debug_cycle_notifications" />
<TextView
android:id="@+id/debug_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="0"
android:textSize="32sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/debug_minus_10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="-10" />
<Button
android:id="@+id/debug_minus_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="-1" />
<Button
android:id="@+id/debug_plus_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="+1" />
<Button
android:id="@+id/debug_plus_10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="+10" />
</LinearLayout>
<Button
android:id="@+id/debug_reset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_reset_notifications" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
@@ -26,10 +26,10 @@
android:textStyle="bold" /> android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/hide_unlocked" android:id="@+id/hide_aod"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Unlocked status bar" /> android:text="Always-on display" />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/hide_lockscreen" android:id="@+id/hide_lockscreen"
@@ -38,10 +38,10 @@
android:text="Collapsed lockscreen" /> android:text="Collapsed lockscreen" />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/hide_aod" android:id="@+id/hide_unlocked"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Always-on display" /> android:text="Unlocked status bar" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/page_padding">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/debug_notifications_description"
android:textSize="14sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_cycle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/debug_cycle_notifications" />
<TextView
android:id="@+id/debug_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="0"
android:textSize="32sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/debug_minus_10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="-10" />
<Button
android:id="@+id/debug_minus_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="-1" />
<Button
android:id="@+id/debug_plus_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="+1" />
<Button
android:id="@+id/debug_plus_10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="+10" />
</LinearLayout>
<Button
android:id="@+id/debug_reset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_reset_notifications" />
</LinearLayout>
</ScrollView>
@@ -15,9 +15,16 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="Requires LSPosed. Unsupported surfaces remain unchanged." android:text="Requires LSPosed. Only apps seen producing a notification since the last boot are listed."
android:textAppearance="?attr/textAppearanceBody2" /> android:textAppearance="?attr/textAppearanceBody2" />
<TextView
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:textAppearance="?attr/textAppearanceCaption" />
<EditText <EditText
android:id="@+id/app_search" android:id="@+id/app_search"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -33,6 +40,11 @@
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_weight="1"> android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/app_list_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/app_list" android:id="@+id/app_list"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -41,6 +53,17 @@
android:fastScrollAlwaysVisible="true" android:fastScrollAlwaysVisible="true"
android:fastScrollEnabled="true" android:fastScrollEnabled="true"
android:paddingBottom="16dp" /> android:paddingBottom="16dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/app_list_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="No notification-producing apps have been seen since boot."
android:textAppearance="?attr/textAppearanceBody2"
android:visibility="gone" />
<ProgressBar <ProgressBar
android:id="@+id/app_list_loading" android:id="@+id/app_list_loading"
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/visibility_app_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="58dp"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingTop="7dp"
android:paddingEnd="4dp"
android:paddingBottom="7dp">
<FrameLayout
android:id="@+id/visibility_aod_cell"
android:layout_width="30dp"
android:layout_height="40dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/visibility_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/visibility_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/visibility_lockscreen_cell"
android:layout_width="30dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/visibility_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/visibility_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/visibility_unlocked_cell"
android:layout_width="30dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/visibility_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/visibility_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
android:id="@+id/visibility_app_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/visibility_app_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBody1"
android:textStyle="bold" />
<TextView
android:id="@+id/visibility_app_package"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceCaption" />
<TextView
android:id="@+id/visibility_app_exceptions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceCaption"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
+6 -1
View File
@@ -21,12 +21,17 @@
<item <item
android:id="@+id/nav_apps" android:id="@+id/nav_apps"
android:title="@string/navigation_indented_apps" /> android:title="@string/navigation_indented_apps" />
</group>
<group android:checkableBehavior="single">
<item <item
android:id="@+id/nav_notification_visibility" android:id="@+id/nav_notification_visibility"
android:title="@string/navigation_indented_notification_visibility" /> android:title="@string/navigation_notifications_header" />
<item <item
android:id="@+id/nav_cards_layout" android:id="@+id/nav_cards_layout"
android:title="@string/navigation_indented_cards_layout" /> android:title="@string/navigation_indented_cards_layout" />
<item
android:id="@+id/nav_debug_notifications"
android:title="@string/navigation_indented_debug_notifications" />
</group> </group>
<group android:checkableBehavior="single"> <group android:checkableBehavior="single">
<item <item
+2
View File
@@ -15,4 +15,6 @@
<color name="app_filter_inherited_icon">#000000</color> <color name="app_filter_inherited_icon">#000000</color>
<color name="app_filter_override_background">#4A4B53</color> <color name="app_filter_override_background">#4A4B53</color>
<color name="app_filter_override_icon">#B8CCFF</color> <color name="app_filter_override_icon">#B8CCFF</color>
<color name="visibility_hidden_cross">#FF8A9A</color>
<color name="visibility_edited_background">#24294775</color>
</resources> </resources>
+2
View File
@@ -15,4 +15,6 @@
<color name="app_filter_inherited_icon">#FFFFFF</color> <color name="app_filter_inherited_icon">#FFFFFF</color>
<color name="app_filter_override_background">#D0D0D8</color> <color name="app_filter_override_background">#D0D0D8</color>
<color name="app_filter_override_icon">#34588E</color> <color name="app_filter_override_icon">#34588E</color>
<color name="visibility_hidden_cross">#B00020</color>
<color name="visibility_edited_background">#183B5C9C</color>
</resources> </resources>
+5 -4
View File
@@ -4,13 +4,13 @@
<string name="notification_listener_label">Notifications Master listener</string> <string name="notification_listener_label">Notifications Master listener</string>
<string name="navigation_open">Open navigation</string> <string name="navigation_open">Open navigation</string>
<string name="navigation_close">Close navigation</string> <string name="navigation_close">Close navigation</string>
<string name="navigation_alerts_header">Notification control</string> <string name="navigation_alerts_header">Alerts</string>
<string name="navigation_notifications_header">Notifications</string>
<string name="navigation_logging_header">Logging</string> <string name="navigation_logging_header">Logging</string>
<string name="page_view_logs">View logs</string> <string name="page_view_logs">View logs</string>
<string name="page_settings">Settings</string> <string name="page_settings">Settings</string>
<string name="page_alerts">Alerts</string> <string name="page_alerts">Alerts</string>
<string name="page_alert_status">Alert status</string> <string name="page_alert_status">Alert status</string>
<string name="debug_notifications_title">Debug notifications</string>
<string name="debug_notifications_description">Post up to 50 real, silent notifications to test icon filtering and layout.</string> <string name="debug_notifications_description">Post up to 50 real, silent notifications to test icon filtering and layout.</string>
<string name="debug_cycle_notifications">Cycle between zero and the selected count every two seconds</string> <string name="debug_cycle_notifications">Cycle between zero and the selected count every two seconds</string>
<string name="debug_reset_notifications">Reset</string> <string name="debug_reset_notifications">Reset</string>
@@ -18,7 +18,8 @@
<string name="page_profiles">Profiles</string> <string name="page_profiles">Profiles</string>
<string name="page_rules">Rules</string> <string name="page_rules">Rules</string>
<string name="page_apps">Apps</string> <string name="page_apps">Apps</string>
<string name="page_notification_visibility">Notification visibility</string> <string name="page_notification_visibility">Notifications</string>
<string name="page_debug_notifications">Debug icons</string>
<string name="page_cards_layout">Cards layout</string> <string name="page_cards_layout">Cards layout</string>
<string name="page_filter_apps">Filter apps</string> <string name="page_filter_apps">Filter apps</string>
<string name="page_log_display">Log display</string> <string name="page_log_display">Log display</string>
@@ -28,8 +29,8 @@
<string name="navigation_indented_profiles">&#160;&#160;&#160;&#160;Profiles</string> <string name="navigation_indented_profiles">&#160;&#160;&#160;&#160;Profiles</string>
<string name="navigation_indented_rules">&#160;&#160;&#160;&#160;Rules</string> <string name="navigation_indented_rules">&#160;&#160;&#160;&#160;Rules</string>
<string name="navigation_indented_apps">&#160;&#160;&#160;&#160;Apps</string> <string name="navigation_indented_apps">&#160;&#160;&#160;&#160;Apps</string>
<string name="navigation_indented_notification_visibility">&#160;&#160;&#160;&#160;Visibility</string>
<string name="navigation_indented_cards_layout">&#160;&#160;&#160;&#160;Cards layout</string> <string name="navigation_indented_cards_layout">&#160;&#160;&#160;&#160;Cards layout</string>
<string name="navigation_indented_debug_notifications">&#160;&#160;&#160;&#160;Debug icons</string>
<string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string> <string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string>
<string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</string> <string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</string>
</resources> </resources>