Add notification drawer visibility controls

This commit is contained in:
ajp_anton
2026-09-14 10:02:08 +00:00
parent eef433ce88
commit 32d0935389
16 changed files with 195 additions and 55 deletions
@@ -21,6 +21,13 @@ class VisibilityModuleFixtureTest {
))))
}
@Test fun blockHelperInDrawerOnly() {
requireFixtureMode()
store.save(VisibilityPolicy(apps = listOf(
AppVisibilityPolicy(HELPER_PACKAGE, setOf(NotificationSurface.DRAWER)),
)))
}
@Test fun allowHelper() {
requireFixtureMode()
store.save(VisibilityPolicy())
@@ -85,6 +85,7 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
bindSurface(binding!!.showAod, NotificationSurface.AOD, policy)
bindSurface(binding!!.showLockscreen, NotificationSurface.LOCKSCREEN_COLLAPSED, policy)
bindSurface(binding!!.showUnlocked, NotificationSurface.UNLOCKED_STATUSBAR, policy)
bindSurface(binding!!.showDrawer, NotificationSurface.DRAWER, policy)
adapter.submit(policy.exceptions)
}
@@ -204,6 +205,7 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
rule,
NotificationSurface.UNLOCKED_STATUSBAR,
)
bindSurface(binding.drawerIcon, binding.drawerCross, rule, NotificationSurface.DRAWER)
binding.aodCell.setOnClickListener { toggle(holder, NotificationSurface.AOD) }
binding.lockscreenCell.setOnClickListener {
toggle(holder, NotificationSurface.LOCKSCREEN_COLLAPSED)
@@ -211,6 +213,7 @@ class AppVisibilityFragment : Fragment(R.layout.fragment_app_visibility) {
binding.unlockedCell.setOnClickListener {
toggle(holder, NotificationSurface.UNLOCKED_STATUSBAR)
}
binding.drawerCell.setOnClickListener { toggle(holder, NotificationSurface.DRAWER) }
binding.edit.setOnClickListener { holder.positionOrNull()?.let(edit) }
binding.delete.setOnClickListener { holder.positionOrNull()?.let(delete) }
binding.dragHandle.setOnTouchListener { _, event ->
@@ -188,6 +188,12 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
row,
NotificationSurface.UNLOCKED_STATUSBAR,
)
bindSurface(
binding.visibilityDrawerIcon,
binding.visibilityDrawerCross,
row,
NotificationSurface.DRAWER,
)
binding.visibilityAodCell.setOnClickListener { toggle(row, NotificationSurface.AOD) }
binding.visibilityLockscreenCell.setOnClickListener {
toggle(row, NotificationSurface.LOCKSCREEN_COLLAPSED)
@@ -195,6 +201,9 @@ class NotificationVisibilityFragment : Fragment(R.layout.fragment_notification_v
binding.visibilityUnlockedCell.setOnClickListener {
toggle(row, NotificationSurface.UNLOCKED_STATUSBAR)
}
binding.visibilityDrawerCell.setOnClickListener {
toggle(row, NotificationSurface.DRAWER)
}
binding.visibilityAppRoot.setOnClickListener { open(row.app.app) }
}
@@ -12,7 +12,7 @@ import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
/** Hides blocked rows only in AOSP's collapsed lockscreen notification stack. */
/** Filters AOSP notification rows for the collapsed lockscreen or notification drawer. */
internal class AospLockscreenBackend(
private val framework: XposedInterface,
) : VisibilitySurfaceBackend {
@@ -21,6 +21,7 @@ internal class AospLockscreenBackend(
private val hiddenRows = WeakHashMap<View, RowState>()
private val groupOrders = WeakHashMap<View, List<View>>()
private lateinit var onKeyguard: Method
private var stackExpanded: Field? = null
private lateinit var entryForRow: (View) -> Any?
private lateinit var sbnForEntry: (Any) -> StatusBarNotification?
private lateinit var attachedChildrenForRow: (View) -> List<View>
@@ -42,6 +43,7 @@ internal class AospLockscreenBackend(
it.returnType == Boolean::class.javaPrimitiveType
}?.also { it.isAccessible = true }
?: error("AOSP lockscreen state method not found")
stackExpanded = findField(stackClass, "mIsExpanded")
prepareNotificationAccess(rowClass)
installShelfFilter(classLoader)
@@ -73,7 +75,9 @@ internal class AospLockscreenBackend(
chain.proceed()
}
}
stackClass.declaredMethods.filter { it.name == "setStatusBarState" }.forEach { method ->
stackClass.declaredMethods.filter {
it.name == "setStatusBarState" || it.name == "setIsExpanded"
}.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
@@ -85,7 +89,7 @@ internal class AospLockscreenBackend(
}
}
}.onSuccess {
Log.i(TAG, "Installed AOSP collapsed-lockscreen notification backend")
Log.i(TAG, "Installed AOSP lockscreen/drawer notification backend")
}.onFailure {
Log.w(TAG, "AOSP lockscreen shape is unsupported; leaving rows unchanged", it)
}.isSuccess
@@ -147,11 +151,11 @@ internal class AospLockscreenBackend(
?: return@intercept chain.proceed()
if (container.parent?.javaClass?.name != SHELF) return@intercept chain.proceed()
synchronized(shelves) { shelves += container }
if (!isCollapsedLockscreen(container)) return@intercept chain.proceed()
val surface = surfaceFor(container) ?: return@intercept chain.proceed()
val children = List(container.childCount, container::getChildAt)
val states = shelfIconStates.get(container) as? Map<*, *>
?: return@intercept chain.proceed()
val blocked = children.filter(::isBlockedShelfIcon)
val blocked = children.filter { isBlockedShelfIcon(it, surface) }
blocked.forEach { states[it]?.let(iconState::hide) }
children.filterNot(blocked::contains).forEach(container::bringChildToFront)
val result = try {
@@ -169,22 +173,27 @@ internal class AospLockscreenBackend(
}
private fun applyStack(stack: ViewGroup) {
val active = isCollapsedLockscreen(stack)
repeat(stack.childCount) { applyRow(stack, stack.getChildAt(it), active) }
val surface = surfaceFor(stack)
repeat(stack.childCount) { applyRow(stack.getChildAt(it), surface) }
stack.requestLayout()
}
private fun applyRow(stack: ViewGroup, row: View, active: Boolean? = null) {
private fun applyRow(stack: ViewGroup, row: View) = applyRow(row, surfaceFor(stack))
private fun applyRow(row: View, surface: NotificationSurface?) {
if (row.javaClass.name != ROW) return
val filter = active ?: isCollapsedLockscreen(stack)
if (!filter) restoreGroupOrder(row)
if (surface == null) {
restoreTree(row)
return
}
restoreGroupOrder(row)
val children = attachedChildrenForRow(row)
val parentBlocked = filter && isBlocked(row)
var blockedChildren = children.map { it to (parentBlocked && isBlocked(it)) }
if (parentBlocked && children.isNotEmpty()) {
val parentBlocked = isBlocked(row, surface)
val blockedChildren = children.map { it to isBlocked(it, surface) }
if (blockedChildren.any { it.second }) {
groupOrders.putIfAbsent(row, children)
blockedChildren = blockedChildren.partition { !it.second }.let { it.first + it.second }
reorderChildren(row, blockedChildren.map { it.first })
val ordered = blockedChildren.partition { !it.second }.let { it.first + it.second }
reorderChildren(row, ordered.map { it.first })
setGroupCount(row, blockedChildren.count { !it.second })
}
blockedChildren.forEach { (child, blocked) ->
@@ -193,18 +202,19 @@ internal class AospLockscreenBackend(
if (parentBlocked && blockedChildren.all { it.second }) hideRow(row) else restoreRow(row)
}
private fun isBlocked(row: View): Boolean {
private fun isBlocked(row: View, surface: NotificationSurface): Boolean {
val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false
return isBlocked(sbn)
return isBlocked(sbn, surface)
}
private fun isBlockedShelfIcon(icon: View): Boolean {
private fun isBlockedShelfIcon(icon: View, surface: NotificationSurface): Boolean {
if (icon.javaClass.name != STATUS_BAR_ICON_VIEW) return false
val sbn = runCatching { shelfNotification.get(icon) as? StatusBarNotification }.getOrNull()
?: return false
val row = rowForKey(icon, sbn.key)
return row?.let { isBlocked(it) && attachedChildrenForRow(it).none { child -> !isBlocked(child) } }
?: isBlocked(sbn)
return row?.let {
isBlocked(it, surface) && attachedChildrenForRow(it).none { child -> !isBlocked(child, surface) }
} ?: isBlocked(sbn, surface)
}
private fun rowForKey(view: View, key: String): View? {
@@ -219,21 +229,27 @@ internal class AospLockscreenBackend(
}
}
private fun isBlocked(sbn: StatusBarNotification): Boolean =
private fun isBlocked(sbn: StatusBarNotification, surface: NotificationSurface): Boolean =
ProcessVisibilityPolicyCache.isBlocked(
VisibilityNotificationExtractor.from(sbn),
NotificationSurface.LOCKSCREEN_COLLAPSED,
surface,
).also { blocked ->
if (blocked && !blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered an AOSP collapsed-lockscreen notification")
Log.i(TAG, "Filtered an AOSP notification from $surface")
}
}
private fun isCollapsedLockscreen(view: View): Boolean {
private fun surfaceFor(view: View): NotificationSurface? {
val root = view.rootView
val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return false
return runCatching { onKeyguard.invoke(stack) as Boolean }.getOrDefault(false)
val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return null
return runCatching {
when {
onKeyguard.invoke(stack) as Boolean -> NotificationSurface.LOCKSCREEN_COLLAPSED
stackExpanded?.getBoolean(stack) == true -> NotificationSurface.DRAWER
else -> null
}
}.getOrNull()
}
private fun hideRow(row: View) {
@@ -12,7 +12,7 @@ import java.util.Collections
import java.util.WeakHashMap
import java.util.function.Function
/** Hides blocked OneUI notification rows only on the collapsed cards lockscreen. */
/** Filters OneUI notification rows in Cards-mode lockscreen and the notification drawer. */
internal class OneUiLockscreenCardsBackend(
private val framework: XposedInterface,
) : VisibilitySurfaceBackend {
@@ -21,6 +21,7 @@ internal class OneUiLockscreenCardsBackend(
private val gridRenderer = OneUiCardsGridRenderer()
private lateinit var entryForRow: (View) -> Any?
private lateinit var sbnForEntry: (Any) -> StatusBarNotification?
private var stackExpanded: Field? = null
private lateinit var shelfNotification: Field
private lateinit var shelfIconStates: Field
private lateinit var iconState: IconStateAccess
@@ -36,6 +37,7 @@ internal class OneUiLockscreenCardsBackend(
val stackClass = Class.forName(STACK, false, classLoader)
val rowClass = Class.forName(ROW, false, classLoader)
val stateController = Class.forName(STATE_CONTROLLER, false, classLoader)
stackExpanded = findField(stackClass, "mIsExpanded")
prepareNotificationAccess(rowClass)
installShelfFilter(classLoader)
installGridSourceHook(classLoader)
@@ -98,7 +100,7 @@ internal class OneUiLockscreenCardsBackend(
}
}
}.onSuccess {
Log.i(TAG, "Installed OneUI lockscreen-cards notification backend")
Log.i(TAG, "Installed OneUI lockscreen-cards/drawer notification backend")
}.onFailure {
Log.d(TAG, "OneUI lockscreen-cards boundary is unavailable", it)
}.isSuccess
@@ -141,11 +143,12 @@ internal class OneUiLockscreenCardsBackend(
if (isDozeActive() && isCardsMode(container)) {
gridRenderer.suspend(container, true)
}
if (!shouldFilterLockscreenCards(container)) return@intercept chain.proceed()
val surface = rowSurface(container) ?: return@intercept chain.proceed()
val lockscreenCards = surface == NotificationSurface.LOCKSCREEN_COLLAPSED
val children = List(container.childCount, container::getChildAt)
val states = shelfIconStates.get(container) as? Map<*, *>
?: return@intercept chain.proceed()
val blocked = children.filter(::isBlockedShelfIcon)
val blocked = children.filter { isBlockedShelfIcon(it, surface) }
blocked.forEach { icon -> states[icon]?.let(iconState::hide) }
children.filterNot(blocked::contains).forEach(container::bringChildToFront)
val result = try {
@@ -153,10 +156,13 @@ internal class OneUiLockscreenCardsBackend(
} finally {
children.forEach(container::bringChildToFront)
}
// OneUI applies these states after this calculation returns. Suppress every
// stock shelf icon in Cards mode; the renderer owns the supplemental grid.
children.forEach { icon -> states[icon]?.let(iconState::hide) }
if (!nativeShelfSuppressedObserved) {
// OneUI applies these states after this calculation returns. In Cards mode,
// the renderer owns the whole supplemental shelf; in the drawer, only hide
// icons whose notification rows are hidden.
(if (lockscreenCards) children else blocked).forEach { icon ->
states[icon]?.let(iconState::hide)
}
if (lockscreenCards && !nativeShelfSuppressedObserved) {
nativeShelfSuppressedObserved = true
Log.i(TAG, "Suppressed the native OneUI Cards shelf icon states")
}
@@ -228,19 +234,32 @@ internal class OneUiLockscreenCardsBackend(
}
private fun applyStack(stack: ViewGroup) {
val active = shouldFilterLockscreenCards(stack)
val surface = rowSurface(stack)
repeat(stack.childCount) { index ->
val row = stack.getChildAt(index)
if (row.javaClass.name == ROW) {
if (active && isBlocked(row)) hideRow(row) else restoreRow(row)
if (surface != null && isBlocked(row, surface)) hideRow(row) else restoreRow(row)
}
}
}
private fun applyRow(row: View) {
val stack = row.parent as? ViewGroup ?: return
val active = shouldFilterLockscreenCards(stack)
if (active && isBlocked(row)) hideRow(row) else restoreRow(row)
val surface = rowSurface(stack)
if (surface != null && isBlocked(row, surface)) hideRow(row) else restoreRow(row)
}
private fun rowSurface(view: View): NotificationSurface? = when {
shouldFilterLockscreenCards(view) -> NotificationSurface.LOCKSCREEN_COLLAPSED
statusBarStateKnown && statusBarState != KEYGUARD && !isDozeActive() && isStackExpanded(view) ->
NotificationSurface.DRAWER
else -> null
}
private fun isStackExpanded(view: View): Boolean {
val root = view.rootView
val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return false
return runCatching { stackExpanded?.getBoolean(stack) == true }.getOrDefault(false)
}
private fun isLockscreenCards(view: View) =
@@ -257,26 +276,29 @@ internal class OneUiLockscreenCardsBackend(
Settings.System.getInt(view.context.contentResolver, LOCKSCREEN_STYLE) == CARDS
}.getOrDefault(false)
private fun isBlocked(row: View): Boolean {
private fun isBlocked(row: View, surface: NotificationSurface): Boolean {
val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false
return isBlocked(sbn)
return isBlocked(sbn, surface)
}
private fun isBlockedShelfIcon(icon: View): Boolean {
private fun isBlockedShelfIcon(icon: View, surface: NotificationSurface): Boolean {
if (icon.javaClass.name != STATUS_BAR_ICON_VIEW) return false
val sbn = runCatching { shelfNotification.get(icon) as? StatusBarNotification }.getOrNull()
?: return false
return isBlocked(sbn)
return isBlocked(sbn, surface)
}
private fun isBlocked(sbn: StatusBarNotification): Boolean {
private fun isBlocked(sbn: StatusBarNotification) =
isBlocked(sbn, NotificationSurface.LOCKSCREEN_COLLAPSED)
private fun isBlocked(sbn: StatusBarNotification, surface: NotificationSurface): Boolean {
return ProcessVisibilityPolicyCache.isBlocked(
VisibilityNotificationExtractor.from(sbn),
NotificationSurface.LOCKSCREEN_COLLAPSED,
surface,
).also { blocked ->
if (blocked && !blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered a OneUI lockscreen notification card")
Log.i(TAG, "Filtered a OneUI notification from $surface")
}
}
}
@@ -2,7 +2,7 @@ package se.ajpanton.notificationsmaster.visibility
import com.google.re2j.Pattern
enum class NotificationSurface { AOD, LOCKSCREEN_COLLAPSED, UNLOCKED_STATUSBAR }
enum class NotificationSurface { AOD, LOCKSCREEN_COLLAPSED, UNLOCKED_STATUSBAR, DRAWER }
data class CardsGridSettings(
val maxRows: Int = 1,
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/visibility_card_background" />
<stroke
android:width="1dp"
android:color="@color/visibility_card_outline" />
<corners android:radius="6dp" />
</shape>
@@ -51,6 +51,12 @@
android:layout_height="wrap_content"
android:text="Unlocked status bar" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/show_drawer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Notification drawer" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -22,7 +22,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Icons represent AOD, Lockscreen and Unlocked, respectively. Tap to toggle visibility.&#10;Tap the app for more advanced options."
android:text="Icons represent AOD, Lockscreen, Unlocked and the notification drawer, respectively. Tap to toggle visibility.&#10;Tap the app for more advanced options."
android:textAppearance="?attr/textAppearanceCaption" />
<EditText
@@ -92,6 +92,35 @@
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/visibility_drawer_cell"
android:layout_width="38dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/visibility_drawer_icon"
android:layout_width="32dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:background="@drawable/bg_visibility_drawer_card"
android:contentDescription="Notification drawer visibility"
android:padding="4dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/visibility_drawer_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"
@@ -115,6 +115,34 @@
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/drawer_cell"
android:layout_width="38dp"
android:layout_height="40dp"
android:foreground="?attr/selectableItemBackgroundBorderless">
<ImageView
android:id="@+id/drawer_icon"
android:layout_width="32dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:background="@drawable/bg_visibility_drawer_card"
android:contentDescription="Notification drawer visibility"
android:padding="4dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/drawer_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>
+2
View File
@@ -17,4 +17,6 @@
<color name="app_filter_override_icon">#B8CCFF</color>
<color name="visibility_hidden_cross">#FF8A9A</color>
<color name="visibility_edited_background">#24294775</color>
<color name="visibility_card_background">#FF3C4043</color>
<color name="visibility_card_outline">#99FFFFFF</color>
</resources>
+2
View File
@@ -17,6 +17,8 @@
<color name="app_filter_override_icon">#34588E</color>
<color name="visibility_hidden_cross">#B00020</color>
<color name="visibility_edited_background">#183B5C9C</color>
<color name="visibility_card_background">#FFF3F3F3</color>
<color name="visibility_card_outline">#61000000</color>
<color name="notification_access_granted">#2E7D32</color>
<color name="notification_access_missing">#B3261E</color>
<color name="launcher_icon_background">#D9E2FF</color>
@@ -14,10 +14,10 @@ class VisibilityPolicyJsonTest {
systemUiMisc = SystemUiMiscSettings(true, false, true),
apps = listOf(AppVisibilityPolicy(
packageName = "example.app",
blockedSurfaces = setOf(NotificationSurface.AOD),
blockedSurfaces = setOf(NotificationSurface.AOD, NotificationSurface.DRAWER),
exceptions = listOf(
VisibilityExceptionRule("first", setOf(NotificationSurface.LOCKSCREEN_COLLAPSED)),
VisibilityExceptionRule("second", emptySet()),
VisibilityExceptionRule("second", setOf(NotificationSurface.DRAWER)),
),
)),
)
@@ -30,16 +30,21 @@ class VisibilityPolicyTest {
}
@Test fun `package defaults are independent by surface`() {
val policy = compiled(defaults = setOf(NotificationSurface.AOD, NotificationSurface.UNLOCKED_STATUSBAR))
val policy = compiled(defaults = setOf(
NotificationSurface.AOD,
NotificationSurface.UNLOCKED_STATUSBAR,
NotificationSurface.DRAWER,
))
assertTrue(policy.isBlocked(notification, NotificationSurface.AOD))
assertFalse(policy.isBlocked(notification, NotificationSurface.LOCKSCREEN_COLLAPSED))
assertTrue(policy.isBlocked(notification, NotificationSurface.UNLOCKED_STATUSBAR))
assertTrue(policy.isBlocked(notification, NotificationSurface.DRAWER))
}
@Test fun `first matching exception replaces the package default`() {
val policy = compiled(
defaults = setOf(NotificationSurface.AOD),
defaults = setOf(NotificationSurface.AOD, NotificationSurface.DRAWER),
exceptions = listOf(
VisibilityExceptionRule("tomorrow", setOf(NotificationSurface.LOCKSCREEN_COLLAPSED)),
VisibilityExceptionRule("Alice", setOf(NotificationSurface.UNLOCKED_STATUSBAR)),
@@ -49,6 +54,7 @@ class VisibilityPolicyTest {
assertFalse(policy.isBlocked(notification, NotificationSurface.AOD))
assertTrue(policy.isBlocked(notification, NotificationSurface.LOCKSCREEN_COLLAPSED))
assertFalse(policy.isBlocked(notification, NotificationSurface.UNLOCKED_STATUSBAR))
assertFalse(policy.isBlocked(notification, NotificationSurface.DRAWER))
}
@Test fun `exceptions match stable notification metadata`() {