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),
).firstOrNull { it.install(param.classLoader) }
systemUiBackends = listOfNotNull(unlocked) + listOf(
AospLockscreenBackend(this),
OneUiLockscreenBackend(this),
OneUiLockscreenCardsBackend(this),
SamsungAodPluginBackend(this),
@@ -74,6 +74,7 @@ class MainActivity : AppCompatActivity() {
"chronometer" -> postChronometer()
"timeout" -> postTimeout()
"group" -> postGroup()
"group_exception" -> postGroup(exceptionText = true)
"cancel_text" -> manager.cancel(TEXT_ID)
"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 postGroup() {
manager.notify(GROUP_CHILD_ONE, base().setContentText("Grouped child one").setGroup(GROUP_KEY).build())
private fun postGroup(exceptionText: Boolean = false) {
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_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 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 group with matching child" to "group_exception",
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
)
}