Filter AOSP lockscreen notifications

This commit is contained in:
ajp_anton
2026-08-31 03:24:33 +00:00
parent eafc544da8
commit b1458b13e3
3 changed files with 325 additions and 2 deletions
@@ -0,0 +1,319 @@
package se.ajpanton.notificationsmaster.module
import android.os.Build
import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.View
import android.view.ViewGroup
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
/** Hides blocked rows only in AOSP's collapsed lockscreen notification stack. */
internal class AospLockscreenBackend(
private val framework: XposedInterface,
) : VisibilitySurfaceBackend {
private val stacks = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val shelves = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val hiddenRows = WeakHashMap<View, RowState>()
private val groupOrders = WeakHashMap<View, List<View>>()
private lateinit var onKeyguard: Method
private lateinit var entryForRow: (View) -> Any?
private lateinit var sbnForEntry: (Any) -> StatusBarNotification?
private lateinit var attachedChildrenForRow: (View) -> List<View>
private lateinit var reorderChildren: (View, List<View>) -> Unit
private lateinit var setGroupCount: (View, Int) -> Unit
private lateinit var calculateShelfIcons: Method
private lateinit var shelfNotification: Field
private lateinit var shelfIconStates: Field
private lateinit var shelfClampedAmount: Field
private lateinit var shelfAppearAmount: Field
private lateinit var shelfAlpha: Field
private lateinit var shelfHidden: Field
private lateinit var shelfVisibleState: Field
@Volatile private var blockedObserved = false
override fun install(classLoader: ClassLoader): Boolean {
if (Build.MANUFACTURER.equals("samsung", ignoreCase = true)) return false
return runCatching {
val stackClass = Class.forName(STACK, false, classLoader)
val rowClass = Class.forName(ROW, false, classLoader)
onKeyguard = stackClass.declaredMethods.singleOrNull {
it.name == "onKeyguard" && it.parameterCount == 0 &&
it.returnType == Boolean::class.javaPrimitiveType
}?.also { it.isAccessible = true }
?: error("AOSP lockscreen state method not found")
prepareNotificationAccess(rowClass)
installShelfFilter(classLoader)
stackClass.declaredMethods.filter { it.name == "onViewAdded" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
(chain.thisObject as? ViewGroup)?.let { stack ->
track(stack)
(chain.args.firstOrNull() as? View)?.let { applyRow(stack, it) }
}
result
}
}
stackClass.declaredMethods.filter { it.name == "onViewRemoved" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
(chain.args.firstOrNull() as? View)?.let(::restoreTree)
chain.proceed()
}
}
stackClass.declaredMethods.filter { it.name == "onMeasure" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
(chain.thisObject as? ViewGroup)?.let {
track(it)
applyStack(it)
}
chain.proceed()
}
}
stackClass.declaredMethods.filter { it.name == "setStatusBarState" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val result = chain.proceed()
(chain.thisObject as? ViewGroup)?.let {
track(it)
applyStack(it)
}
result
}
}
}.onSuccess {
Log.i(TAG, "Installed AOSP collapsed-lockscreen notification backend")
}.onFailure {
Log.w(TAG, "AOSP lockscreen shape is unsupported; leaving rows unchanged", it)
}.isSuccess
}
override fun onPolicyChanged() {
blockedObserved = false
synchronized(stacks) { stacks.toList() }.forEach(::applyStack)
synchronized(shelves) { shelves.toList() }.forEach { shelf ->
runCatching { calculateShelfIcons.invoke(shelf) }
.onFailure { Log.w(TAG, "Could not refresh AOSP lockscreen shelf", it) }
}
}
private fun prepareNotificationAccess(rowClass: Class<*>) {
val entryMethod = rowClass.methods.firstOrNull { it.name == "getEntry" && it.parameterCount == 0 }
val entryField = findField(rowClass, "mEntry", "entry")
require(entryMethod != null || entryField != null) { "AOSP notification row entry is unavailable" }
entryForRow = accessor(entryMethod, entryField)
val entryClass = entryMethod?.returnType ?: entryField!!.type
val sbnMethod = entryClass.methods.firstOrNull { it.name == "getSbn" && it.parameterCount == 0 }
val sbnField = findField(entryClass, "mSbn", "sbn", "mStatusBarNotification")
require(sbnMethod != null || sbnField != null) { "AOSP notification entry SBN is unavailable" }
val accessor = accessor(sbnMethod, sbnField)
sbnForEntry = { accessor(it) as? StatusBarNotification }
val attachedChildren = rowClass.methods.firstOrNull {
it.name == "getAttachedChildren" && it.parameterCount == 0
}?.also { it.isAccessible = true }
attachedChildrenForRow = { row ->
(attachedChildren?.invoke(row) as? List<*>)?.filterIsInstance<View>().orEmpty()
}
reorderChildren = { row, ordered ->
@Suppress("UNCHECKED_CAST")
(attachedChildren?.invoke(row) as? MutableList<Any?>)?.let {
it.clear()
it.addAll(ordered)
}
}
val childrenContainer = requireField(rowClass, "mChildrenContainer")
val untruncatedCount = requireField(childrenContainer.type, "mUntruncatedChildCount")
setGroupCount = { row, count ->
childrenContainer.get(row)?.let { untruncatedCount.setInt(it, count) }
}
}
private fun installShelfFilter(classLoader: ClassLoader) {
val iconContainer = Class.forName(ICON_CONTAINER, false, classLoader)
val iconView = Class.forName(STATUS_BAR_ICON_VIEW, false, classLoader)
val iconState = Class.forName(ICON_STATE, false, classLoader)
calculateShelfIcons = iconContainer.declaredMethods.singleOrNull {
it.name == "calculateIconXTranslations" && it.parameterCount == 0
}?.also { it.isAccessible = true } ?: error("AOSP shelf icon layout method not found")
shelfNotification = requireField(iconView, "mNotification")
shelfIconStates = requireField(iconContainer, "mIconStates")
shelfClampedAmount = requireField(iconState, "clampedAppearAmount")
shelfAppearAmount = requireField(iconState, "iconAppearAmount")
shelfAlpha = requireField(iconState, "mAlpha")
shelfHidden = requireField(iconState, "hidden")
shelfVisibleState = requireField(iconState, "visibleState")
framework.hook(calculateShelfIcons).intercept { chain ->
val container = chain.thisObject as? ViewGroup
?: 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 children = List(container.childCount, container::getChildAt)
val states = shelfIconStates.get(container) as? Map<*, *>
?: return@intercept chain.proceed()
val blocked = children.filter(::isBlockedShelfIcon)
blocked.forEach { states[it]?.let(::hideShelfIconState) }
children.filterNot(blocked::contains).forEach(container::bringChildToFront)
val result = try {
chain.proceed()
} finally {
children.forEach(container::bringChildToFront)
}
blocked.forEach { states[it]?.let(::hideShelfIconState) }
result
}
}
private fun track(stack: ViewGroup) {
synchronized(stacks) { stacks += stack }
}
private fun applyStack(stack: ViewGroup) {
val active = isCollapsedLockscreen(stack)
repeat(stack.childCount) { applyRow(stack, stack.getChildAt(it), active) }
stack.requestLayout()
}
private fun applyRow(stack: ViewGroup, row: View, active: Boolean? = null) {
if (row.javaClass.name != ROW) return
val filter = active ?: isCollapsedLockscreen(stack)
if (!filter) restoreGroupOrder(row)
val children = attachedChildrenForRow(row)
val parentBlocked = filter && isBlocked(row)
var blockedChildren = children.map { it to (parentBlocked && isBlocked(it)) }
if (parentBlocked && children.isNotEmpty()) {
groupOrders.putIfAbsent(row, children)
blockedChildren = blockedChildren.partition { !it.second }.let { it.first + it.second }
reorderChildren(row, blockedChildren.map { it.first })
setGroupCount(row, blockedChildren.count { !it.second })
}
blockedChildren.forEach { (child, blocked) ->
if (blocked) hideRow(child) else restoreRow(child)
}
if (parentBlocked && blockedChildren.all { it.second }) hideRow(row) else restoreRow(row)
}
private fun isBlocked(row: View): Boolean {
val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false
return isBlocked(sbn)
}
private fun isBlockedShelfIcon(icon: View): 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)
}
private fun rowForKey(view: View, key: String): View? {
val root = view.rootView
return synchronized(stacks) { stacks.toList() }
.asSequence()
.filter { it.rootView === root }
.flatMap { stack -> (0 until stack.childCount).asSequence().map(stack::getChildAt) }
.firstOrNull { row ->
row.javaClass.name == ROW &&
runCatching { entryForRow(row)?.let(sbnForEntry)?.key == key }.getOrDefault(false)
}
}
private fun isBlocked(sbn: StatusBarNotification): Boolean =
ProcessVisibilityPolicyCache.isBlocked(
VisibilityNotificationExtractor.from(sbn),
NotificationSurface.LOCKSCREEN_COLLAPSED,
).also { blocked ->
if (blocked && !blockedObserved) {
blockedObserved = true
Log.i(TAG, "Filtered an AOSP collapsed-lockscreen notification")
}
}
private fun isCollapsedLockscreen(view: View): Boolean {
val root = view.rootView
val stack = synchronized(stacks) { stacks.firstOrNull { it.rootView === root } } ?: return false
return runCatching { onKeyguard.invoke(stack) as Boolean }.getOrDefault(false)
}
private fun hideShelfIconState(state: Any) {
shelfClampedAmount.setFloat(state, 0f)
shelfAppearAmount.setFloat(state, 0f)
shelfAlpha.setFloat(state, 0f)
shelfHidden.setBoolean(state, true)
shelfVisibleState.setInt(state, ICON_HIDDEN)
}
private fun hideRow(row: View) {
if (row.visibility == View.GONE && row.alpha == 0f) return
hiddenRows.putIfAbsent(row, RowState(row.visibility, row.alpha))
row.visibility = View.GONE
row.alpha = 0f
}
private fun restoreRow(row: View) {
val state = hiddenRows.remove(row) ?: return
row.visibility = state.visibility
row.alpha = state.alpha
row.requestLayout()
(row.parent as? View)?.requestLayout()
}
private fun restoreTree(row: View) {
restoreGroupOrder(row)
if (row.javaClass.name == ROW) attachedChildrenForRow(row).forEach(::restoreRow)
restoreRow(row)
}
private fun restoreGroupOrder(row: View) {
val saved = groupOrders.remove(row) ?: return
val current = attachedChildrenForRow(row)
reorderChildren(row, saved.filter(current::contains) + current.filterNot(saved::contains))
setGroupCount(row, current.size)
}
private fun accessor(method: Method?, field: Field?): (Any) -> Any? {
method?.isAccessible = true
field?.isAccessible = true
return if (method != null) ({ target -> method.invoke(target) }) else ({ target -> field!!.get(target) })
}
private fun findField(type: Class<*>, vararg names: String): Field? {
var current: Class<*>? = type
while (current != null) {
names.forEach { name ->
runCatching { current.getDeclaredField(name) }.getOrNull()?.let {
it.isAccessible = true
return it
}
}
current = current.superclass
}
return null
}
private fun requireField(type: Class<*>, name: String) =
findField(type, name) ?: error("AOSP $name field is unavailable")
private data class RowState(val visibility: Int, val alpha: Float)
private companion object {
const val TAG = "NotificationsMaster"
const val STACK = "com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout"
const val ROW = "com.android.systemui.statusbar.notification.row.ExpandableNotificationRow"
const val SHELF = "com.android.systemui.statusbar.NotificationShelf"
const val ICON_CONTAINER = "com.android.systemui.statusbar.phone.NotificationIconContainer"
const val ICON_STATE = "$ICON_CONTAINER\$IconState"
const val STATUS_BAR_ICON_VIEW = "com.android.systemui.statusbar.StatusBarIconView"
const val ICON_HIDDEN = 2
}
}
@@ -24,6 +24,7 @@ class NotificationsMasterModule : XposedModule() {
AospModernIconBackend(this), AospModernIconBackend(this),
).firstOrNull { it.install(param.classLoader) } ).firstOrNull { it.install(param.classLoader) }
systemUiBackends = listOfNotNull(unlocked) + listOf( systemUiBackends = listOfNotNull(unlocked) + listOf(
AospLockscreenBackend(this),
OneUiLockscreenBackend(this), OneUiLockscreenBackend(this),
OneUiLockscreenCardsBackend(this), OneUiLockscreenCardsBackend(this),
SamsungAodPluginBackend(this), SamsungAodPluginBackend(this),
@@ -74,6 +74,7 @@ class MainActivity : AppCompatActivity() {
"chronometer" -> postChronometer() "chronometer" -> postChronometer()
"timeout" -> postTimeout() "timeout" -> postTimeout()
"group" -> postGroup() "group" -> postGroup()
"group_exception" -> postGroup(exceptionText = true)
"cancel_text" -> manager.cancel(TEXT_ID) "cancel_text" -> manager.cancel(TEXT_ID)
"cancel_all" -> manager.cancelAll() "cancel_all" -> manager.cancelAll()
} }
@@ -120,8 +121,9 @@ class MainActivity : AppCompatActivity() {
private fun postTimeout() = manager.notify(TIMEOUT_ID, base().setContentTitle("Timeout test").setContentText("This should expire automatically.").setTimeoutAfter(2_000).build()) private fun postTimeout() = manager.notify(TIMEOUT_ID, base().setContentTitle("Timeout test").setContentText("This should expire automatically.").setTimeoutAfter(2_000).build())
private fun postGroup() { private fun postGroup(exceptionText: Boolean = false) {
manager.notify(GROUP_CHILD_ONE, base().setContentText("Grouped child one").setGroup(GROUP_KEY).build()) val first = if (exceptionText) "Edited message" else "Grouped child one"
manager.notify(GROUP_CHILD_ONE, base().setContentText(first).setGroup(GROUP_KEY).build())
manager.notify(GROUP_CHILD_TWO, base().setContentText("Grouped child two").setGroup(GROUP_KEY).build()) manager.notify(GROUP_CHILD_TWO, base().setContentText("Grouped child two").setGroup(GROUP_KEY).build())
manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build()) manager.notify(GROUP_SUMMARY, base().setContentTitle("Grouped summary").setGroup(GROUP_KEY).setGroupSummary(true).build())
} }
@@ -150,6 +152,7 @@ class MainActivity : AppCompatActivity() {
"Post very long text" to "very_long_text", "Post very long text" to "very_long_text",
"Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update", "Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update",
"Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group", "Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group",
"Post group with matching child" to "group_exception",
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all", "Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
) )
} }