From 2bf35884dca6fe8b2fa20649d4c522ac0fef963a Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 30 Aug 2026 23:31:16 +0000 Subject: [PATCH] fix: stabilize OneUI AOD notification grids --- .../module/OneUiCardsGridRenderer.kt | 69 +++++++++++++++++-- .../module/SamsungAodBackend.kt | 60 ++++++++-------- 2 files changed, 92 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt index 6b3df84..ce6c3a7 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt @@ -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? = null + val sourceLocation = IntArray(2) + val rootLocation = IntArray(2) var lastDiagnosticSignature = 0 var config = Config(CardsGridSettings.LOCKSCREEN_DEFAULT, showPills = true, hideShelf = true) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt index a5d62c7..9e8dc65 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt @@ -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()) private val cardsDozing = Collections.synchronizedMap(WeakHashMap()) private val cardsTransitions = Collections.synchronizedMap(WeakHashMap()) - private val cardsLocations = Collections.synchronizedMap(WeakHashMap>()) private val cardsRenderer = OneUiCardsGridRenderer() private val mainHandler = Handler(Looper.getMainLooper()) private val refreshing = ThreadLocal() private lateinit var updateActive: Method private lateinit var updateVisible: Method @Volatile private var blockedObserved = false - @Volatile private var visibleNotifications = emptyList() + @Volatile private var notificationIconViews = emptyList() 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, + filtered: List = 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) } + private fun imageViewList(value: Any?): List? { + val list = value as? ArrayList<*> ?: return null + if (list.any { it !is ImageView }) return null + @Suppress("UNCHECKED_CAST") + return ArrayList(list as List) + } + private fun state(manager: Any) = synchronized(managers) { managers.getOrPut(manager, ::State) } private fun Class<*>.exactMethod(name: String, vararg parameters: Class<*>) =