From c831e9adfbf42ab770efdd2d876fa193569af60f Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sat, 29 Aug 2026 20:13:28 +0000 Subject: [PATCH] Filter OneUI lockscreen notification cards --- .../module/NotificationsMasterModule.kt | 1 + .../module/OneUiLockscreenCardsBackend.kt | 214 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt index 2bb8b46..7911ae5 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -25,6 +25,7 @@ class NotificationsMasterModule : XposedModule() { ).firstOrNull { it.install(param.classLoader) } systemUiBackends = listOfNotNull(unlocked) + listOf( OneUiLockscreenBackend(this), + OneUiLockscreenCardsBackend(this), ).filter { it.install(param.classLoader) } ProcessVisibilityPolicyCache.installWhenReady { systemUiBackends.orEmpty().forEach(VisibilitySurfaceBackend::onPolicyChanged) diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt new file mode 100644 index 0000000..3652b45 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt @@ -0,0 +1,214 @@ +package se.ajpanton.notificationsmaster.module + +import android.provider.Settings +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 OneUI notification rows only on the collapsed cards lockscreen. */ +internal class OneUiLockscreenCardsBackend( + private val framework: XposedInterface, +) : VisibilitySurfaceBackend { + private val stacks = Collections.newSetFromMap(WeakHashMap()) + private val hiddenRows = WeakHashMap() + private lateinit var entryForRow: (View) -> Any? + private lateinit var sbnForEntry: (Any) -> StatusBarNotification? + @Volatile private var statusBarStateKnown = false + @Volatile private var statusBarState = SHADE + @Volatile private var dozing = false + @Volatile private var blockedObserved = false + + override fun install(classLoader: ClassLoader): Boolean = runCatching { + Class.forName(ONE_UI_LOCKSCREEN_COORDINATOR, false, classLoader) + val stackClass = Class.forName(STACK, false, classLoader) + val rowClass = Class.forName(ROW, false, classLoader) + val stateController = Class.forName(STATE_CONTROLLER, false, classLoader) + prepareNotificationAccess(rowClass) + + 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 -> + synchronized(stacks) { stacks += stack } + (chain.args.firstOrNull() as? View)?.let(::applyRow) + } + result + } + } + stackClass.declaredMethods.filter { it.name == "onViewRemoved" }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + (chain.args.firstOrNull() as? View)?.let(::restoreRow) + chain.proceed() + } + } + stackClass.declaredMethods.filter { it.name == "onMeasure" }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + (chain.thisObject as? ViewGroup)?.let { stack -> + synchronized(stacks) { stacks += stack } + applyStack(stack) + } + chain.proceed() + } + } + stateController.declaredConstructors.forEach { constructor -> + constructor.isAccessible = true + framework.hook(constructor).intercept { chain -> + val result = chain.proceed() + updateState(chain.thisObject) + result + } + } + stateController.declaredMethods.filter { + it.name == "setState" || it.name == "setDozeAmountInternal" + }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + val result = chain.proceed() + updateState(chain.thisObject) + result + } + } + }.onSuccess { + Log.i(TAG, "Installed OneUI lockscreen-cards notification backend") + }.onFailure { + Log.d(TAG, "OneUI lockscreen-cards boundary is unavailable", it) + }.isSuccess + + override fun onPolicyChanged() { + blockedObserved = false + applyTrackedStacks() + } + + 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) { "OneUI 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) { "OneUI notification entry SBN is unavailable" } + val accessor = accessor(sbnMethod, sbnField) + sbnForEntry = { accessor(it) as? StatusBarNotification } + } + + 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 updateState(controller: Any) { + (findField(controller.javaClass, "mState")?.get(controller) as? Int)?.let { + statusBarState = it + statusBarStateKnown = true + } + (findField(controller.javaClass, "mIsDozing")?.get(controller) as? Boolean)?.let { dozing = it } + applyTrackedStacks() + } + + private fun applyTrackedStacks() { + synchronized(stacks) { stacks.toList() }.forEach(::applyStack) + } + + private fun applyStack(stack: ViewGroup) { + val active = statusBarStateKnown && statusBarState == KEYGUARD && !dozing && isCardsMode(stack) + repeat(stack.childCount) { index -> + val row = stack.getChildAt(index) + if (row.javaClass.name == ROW) { + if (active && isBlocked(row)) hideRow(row) else restoreRow(row) + } + } + } + + private fun applyRow(row: View) { + val stack = row.parent as? ViewGroup ?: return + val active = statusBarStateKnown && statusBarState == KEYGUARD && !dozing && isCardsMode(stack) + if (active && isBlocked(row)) hideRow(row) else restoreRow(row) + } + + private fun isCardsMode(view: View) = runCatching { + Settings.System.getInt(view.context.contentResolver, LOCKSCREEN_STYLE) == CARDS + }.getOrDefault(false) + + private fun isBlocked(row: View): Boolean { + val sbn = runCatching { entryForRow(row)?.let(sbnForEntry) }.getOrNull() ?: return false + return ProcessVisibilityPolicyCache.isBlocked( + VisibilityNotificationExtractor.from(sbn), + NotificationSurface.LOCKSCREEN_COLLAPSED, + ).also { blocked -> + if (blocked && !blockedObserved) { + blockedObserved = true + Log.i(TAG, "Filtered a OneUI lockscreen notification card") + } + } + } + + 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 + setBooleanField(row, "mWillBeGone", false) + setBooleanField(row, "mInShelf", false) + setBooleanField(row, "mTransformingInShelf", false) + findField(row.javaClass, "mViewState")?.get(row)?.let { viewState -> + setBooleanField(viewState, "hidden", false) + setBooleanField(viewState, "gone", false) + } + row.requestLayout() + (row.parent as? View)?.requestLayout() + } + + private fun setBooleanField(target: Any, name: String, value: Boolean) { + findField(target.javaClass, name)?.let { field -> + if (field.get(target) is Boolean) field.setBoolean(target, value) + } + } + + 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 data class RowState(val visibility: Int, val alpha: Float) + + private companion object { + const val TAG = "NotificationsMaster" + const val SHADE = 0 + const val KEYGUARD = 1 + const val CARDS = 0 + const val LOCKSCREEN_STYLE = "lockscreen_minimizing_notification" + const val STACK = "com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout" + const val ROW = "com.android.systemui.statusbar.notification.row.ExpandableNotificationRow" + const val STATE_CONTROLLER = "com.android.systemui.statusbar.StatusBarStateControllerImpl" + const val ONE_UI_LOCKSCREEN_COORDINATOR = + "com.android.systemui.statusbar.notification.collection.coordinator.LockScreenNotiIconCoordinator" + } +}