fix: stabilize OneUI AOD notification grids

This commit is contained in:
ajp_anton
2026-08-30 23:31:16 +00:00
parent 4ff37e6699
commit 2bf35884dc
2 changed files with 92 additions and 37 deletions
@@ -39,7 +39,7 @@ internal class OneUiCardsGridRenderer {
val native = if (config.hideShelf) findNativeSurface(container) else container
if (native !== container.rootView) hideNative(state, native)
}
container.post { render(container, state, state.active) }
scheduleRender(container, state)
}
fun refreshAll(
@@ -69,6 +69,7 @@ internal class OneUiCardsGridRenderer {
fun suspend(container: ViewGroup, suppressNative: Boolean, nativeSurface: View? = null) {
val state = state(container)
state.active = false
cancelRender(container, state)
state.host?.visibility = View.GONE
if (suppressNative) {
val native = nativeSurface
@@ -84,6 +85,7 @@ internal class OneUiCardsGridRenderer {
restoreNative(state)
state.attachListener?.let(container::removeOnAttachStateChangeListener)
removePreDrawListener(state)
cancelRender(container, state)
state.layoutListener?.let { state.root?.removeOnLayoutChangeListener(it) }
state.host?.let { host ->
clearHost(host)
@@ -91,11 +93,24 @@ internal class OneUiCardsGridRenderer {
}
}
private fun render(container: ViewGroup, state: State, active: Boolean) {
if (!active || !container.isAttachedToWindow) {
failOpen(state)
return
private fun scheduleRender(container: ViewGroup, state: State) {
if (state.renderTask != null) return
val task = Runnable {
state.renderTask = null
if (states[container] === state) render(container, state)
}
state.renderTask = task
if (!container.post(task)) state.renderTask = null
}
private fun cancelRender(container: ViewGroup, state: State) {
state.renderTask?.let(container::removeCallbacks)
state.renderTask = null
}
private fun render(container: ViewGroup, state: State) {
if (!state.active) return
if (!container.isAttachedToWindow) return failOpen(state)
val root = container.rootView as? ViewGroup ?: return failOpen(state)
if (state.config.settings.maxRows == 0) {
state.host?.let {
@@ -128,6 +143,10 @@ internal class OneUiCardsGridRenderer {
clearHost(host)
host.visibility = View.VISIBLE
host.alpha = 1f
host.translationX = 0f
host.translationY = 0f
state.anchorLocation = location
val rowGap = dp(root, 8)
val nativeSurface = if (state.config.hideShelf) findNativeSurface(container) else container
if (nativeSurface === root) return failOpen(state)
@@ -152,6 +171,7 @@ internal class OneUiCardsGridRenderer {
}
}
hideNative(state, nativeSurface)
syncSourceMovement(container, state)
val diagnosticSignature = listOf(remaining.size, state.visibleCardKeys.size, rows.visibleSlots,
settings.maxRows, settings.maxIconsPerRow, iconSize).hashCode()
if (diagnosticSignature != state.lastDiagnosticSignature) {
@@ -294,7 +314,12 @@ internal class OneUiCardsGridRenderer {
private fun failOpen(state: State) {
restoreNative(state)
state.host?.visibility = View.GONE
state.host?.let {
it.visibility = View.GONE
it.translationX = 0f
it.translationY = 0f
}
state.anchorLocation = null
}
private fun hideNative(state: State, native: View) {
@@ -328,6 +353,7 @@ internal class OneUiCardsGridRenderer {
if (state.nativeSuppressed) state.nativeSurface?.let { native ->
if (native.alpha != 0f) native.alpha = 0f
}
if (state.active && state.config.followSourceMovement) syncSourceMovement(container, state)
true
}
observer.addOnPreDrawListener(listener)
@@ -367,6 +393,32 @@ internal class OneUiCardsGridRenderer {
private fun dp(view: View, value: Int) = (value * view.resources.displayMetrics.density).toInt()
private fun syncSourceMovement(container: ViewGroup, state: State) {
val root = state.root ?: return
val host = state.host ?: return
val anchor = state.anchorLocation ?: return
container.getLocationOnScreen(state.sourceLocation)
root.getLocationOnScreen(state.rootLocation)
val x = (state.sourceLocation[0] - state.rootLocation[0] - anchor.first).toFloat()
val y = (state.sourceLocation[1] - state.rootLocation[1] - anchor.second).toFloat()
val alpha = effectiveAncestorAlpha(container, root)
if (host.translationX != x) host.translationX = x
if (host.translationY != y) host.translationY = y
if (host.alpha != alpha) host.alpha = alpha
}
private fun effectiveAncestorAlpha(source: View, root: View): Float {
if (source.visibility != View.VISIBLE) return 0f
var alpha = 1f
var current = source.parent as? View
while (current != null && current !== root) {
if (current.visibility != View.VISIBLE) return 0f
alpha *= current.alpha
current = current.parent as? View
}
return alpha
}
private fun state(container: ViewGroup) = states.getOrPut(container) {
State().also { state ->
state.attachListener = object : View.OnAttachStateChangeListener {
@@ -389,6 +441,7 @@ internal class OneUiCardsGridRenderer {
val settings: CardsGridSettings,
val showPills: Boolean,
val hideShelf: Boolean,
val followSourceMovement: Boolean = false,
)
private class State {
@@ -403,6 +456,10 @@ internal class OneUiCardsGridRenderer {
var nativeSuppressed = false
var preDrawObserver: ViewTreeObserver? = null
var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null
var renderTask: Runnable? = null
var anchorLocation: Pair<Int, Int>? = null
val sourceLocation = IntArray(2)
val rootLocation = IntArray(2)
var lastDiagnosticSignature = 0
var config = Config(CardsGridSettings.LOCKSCREEN_DEFAULT, showPills = true, hideShelf = true)
}
@@ -1,6 +1,5 @@
package se.ajpanton.notificationsmaster.module
import android.graphics.drawable.Icon
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
@@ -9,6 +8,7 @@ import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import io.github.libxposed.api.XposedInterface
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Method
@@ -21,14 +21,13 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
private val cardsContainers = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val cardsDozing = Collections.synchronizedMap(WeakHashMap<ViewGroup, Boolean>())
private val cardsTransitions = Collections.synchronizedMap(WeakHashMap<ViewGroup, CardsTransition>())
private val cardsLocations = Collections.synchronizedMap(WeakHashMap<ViewGroup, Pair<Int, Int>>())
private val cardsRenderer = OneUiCardsGridRenderer()
private val mainHandler = Handler(Looper.getMainLooper())
private val refreshing = ThreadLocal<Boolean>()
private lateinit var updateActive: Method
private lateinit var updateVisible: Method
@Volatile private var blockedObserved = false
@Volatile private var visibleNotifications = emptyList<StatusBarNotification>()
@Volatile private var notificationIconViews = emptyList<ImageView>()
private var cardsRefreshTask: Runnable? = null
override fun install(classLoader: ClassLoader): Boolean = runCatching {
@@ -40,6 +39,7 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
List::class.java,
Int::class.javaPrimitiveType!!,
)
val updateIcons = manager.exactMethod("updateNotification", ArrayList::class.java)
installCardsGrid(classLoader)
framework.hook(updateActive).intercept { chain ->
val raw = notificationList(chain.args[0]) ?: return@intercept chain.proceed()
@@ -54,17 +54,23 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
}
val filteredFirst = filter(first)
val filteredSecond = filter(second)
visibleNotifications = filteredSecond
val result = if (filteredFirst === first && filteredSecond === second) chain.proceed() else {
chain.proceed(arrayOf(filteredFirst, filteredSecond, chain.args[2]))
}
refreshCardsGrid()
result
}
framework.hook(updateIcons).intercept { chain ->
val icons = imageViewList(chain.args[0]) ?: return@intercept chain.proceed()
val result = chain.proceed()
notificationIconViews = icons
refreshCardsGrid()
result
}
}.onSuccess {
Log.i(TAG, "Installed Samsung AOD notification backend")
}.onFailure {
Log.d(TAG, "Samsung AOD notification boundary is unavailable")
}.onFailure { error ->
Log.w(TAG, "Samsung AOD notification boundary is unavailable", error)
}.isSuccess
override fun onPolicyChanged() {
@@ -118,7 +124,6 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
cardsDozing[view] = dozing
if (dozing) beginDozeTransition(view, origin ?: screenLocation(view)) else {
cardsTransitions.remove(view)
cardsLocations.remove(view)
cardsRenderer.suspend(view, isCardsMode(view), view)
}
}
@@ -147,20 +152,17 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
}
container.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) {
if (cardsDozing[container] == true) beginLayoutSettle(container) else refreshCardsGrid()
if (cardsDozing[container] == true) beginAttachedTransition(container) else refreshCardsGrid()
}
override fun onViewDetachedFromWindow(view: View) {
cardsDozing[container] = false
cardsTransitions.remove(container)
cardsLocations.remove(container)
cardsRenderer.release(container)
}
})
container.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) {
if (cardsDozing[container] == true) {
if (cardsTransitions[container] == null) beginLayoutSettle(container) else refreshCardsGrid()
} else refreshCardsGrid()
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
refreshCardsGrid()
}
}
refreshCardsGrid()
@@ -180,12 +182,11 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
refreshCardsGrid(POSITION_POLL_DELAY)
}
private fun beginLayoutSettle(container: ViewGroup) {
private fun beginAttachedTransition(container: ViewGroup) {
val now = SystemClock.uptimeMillis()
val current = screenLocation(container)
val origin = cardsLocations[container] ?: current
cardsTransitions[container] = CardsTransition(
origin,
current,
current,
moved = true,
stableSince = now,
@@ -221,7 +222,6 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
val location = screenLocation(container)
val transition = cardsTransitions[container]
if (transition == null) {
cardsLocations[container] = location
ready += container
return@forEach
}
@@ -236,7 +236,6 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
now >= transition.deadline
) {
cardsTransitions.remove(container)
cardsLocations[container] = location
ready += container
} else {
pollAgain = true
@@ -244,26 +243,18 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
}
if (pollAgain) refreshCardsGrid(POSITION_POLL_DELAY)
if (ready.isEmpty()) return
val notifications = visibleNotifications.distinctBy(StatusBarNotification::getKey)
val config = OneUiCardsGridRenderer.Config(
ProcessVisibilityPolicyCache.aodCards,
showPills = false,
hideShelf = false,
followSourceMovement = true,
)
ready.forEach { container ->
val tint = runCatching {
container.javaClass.getMethod("getIconColor").invoke(container) as Int
}.getOrNull()
val sources = notifications.map { sbn ->
val icon = sbn.notification.smallIcon
val sources = notificationIconViews.map { icon ->
OneUiCardsGridRenderer.Source(
sbn.key,
view = null,
"aod@${System.identityHashCode(icon)}",
icon,
sbn.packageName,
tint.takeUnless {
icon.type == Icon.TYPE_BITMAP || icon.type == Icon.TYPE_ADAPTIVE_BITMAP
},
smallIcon = null,
)
}
cardsRenderer.updateSources(container, sources)
@@ -290,8 +281,8 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
chain: XposedInterface.Chain,
argument: Int,
raw: List<StatusBarNotification>,
filtered: List<StatusBarNotification> = filter(raw),
): Any? {
val filtered = filter(raw)
if (filtered === raw) return chain.proceed()
val args = chain.args.toTypedArray()
args[argument] = filtered
@@ -322,6 +313,13 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
return ArrayList(list as List<StatusBarNotification>)
}
private fun imageViewList(value: Any?): List<ImageView>? {
val list = value as? ArrayList<*> ?: return null
if (list.any { it !is ImageView }) return null
@Suppress("UNCHECKED_CAST")
return ArrayList(list as List<ImageView>)
}
private fun state(manager: Any) = synchronized(managers) { managers.getOrPut(manager, ::State) }
private fun Class<*>.exactMethod(name: String, vararg parameters: Class<*>) =