From 192bdb53816d345de1d1940dfe2ae124e9f74269 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 30 Aug 2026 12:11:35 +0000 Subject: [PATCH] Own OneUI Cards shelves during transitions --- .../module/OneUiCardsGridRenderer.kt | 100 +++++++++++++-- .../module/OneUiLockscreenCardsBackend.kt | 68 ++++++---- .../module/SamsungAodBackend.kt | 121 +++++++++++++----- 3 files changed, 224 insertions(+), 65 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 ba886fa..6b3df84 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt @@ -11,6 +11,7 @@ import android.graphics.drawable.GradientDrawable import android.util.Log import android.view.View import android.view.ViewGroup +import android.view.ViewTreeObserver import android.widget.FrameLayout import android.widget.ImageView import se.ajpanton.notificationsmaster.visibility.CardsGridLayout @@ -21,6 +22,7 @@ import java.util.WeakHashMap /** Renders a OneUI Cards-mode icon grid from notification icon snapshots. */ internal class OneUiCardsGridRenderer { private val states = WeakHashMap() + private val nativeSurfaces = WeakHashMap() fun updateSources(container: ViewGroup, sources: List) { val unique = LinkedHashMap() @@ -33,6 +35,10 @@ internal class OneUiCardsGridRenderer { state.active = active state.visibleCardKeys = visibleCardKeys state.config = config + if (active) { + val native = if (config.hideShelf) findNativeSurface(container) else container + if (native !== container.rootView) hideNative(state, native) + } container.post { render(container, state, state.active) } } @@ -40,27 +46,44 @@ internal class OneUiCardsGridRenderer { active: (ViewGroup) -> Boolean, visibleCardKeys: (ViewGroup) -> Set, config: Config, + suppressNativeWhenInactive: (ViewGroup) -> Boolean = { false }, ) { states.keys.toList().forEach { container -> - refresh(container, active(container), visibleCardKeys(container), config) + if (active(container)) { + refresh(container, true, visibleCardKeys(container), config) + } else { + state(container).config = config + suspend(container, suppressNativeWhenInactive(container)) + } } } fun deactivate(container: ViewGroup) { - states[container]?.let { state -> - state.active = false - failOpen(state) - } + states[container]?.let { suspend(container, false) } } fun deactivateAll() { states.keys.toList().forEach(::deactivate) } + fun suspend(container: ViewGroup, suppressNative: Boolean, nativeSurface: View? = null) { + val state = state(container) + state.active = false + state.host?.visibility = View.GONE + if (suppressNative) { + val native = nativeSurface + ?: if (state.config.hideShelf) findNativeSurface(container) else container + if (native !== container.rootView) hideNative(state, native) + } else { + restoreNative(state) + } + } + fun release(container: ViewGroup) { val state = states.remove(container) ?: return restoreNative(state) state.attachListener?.let(container::removeOnAttachStateChangeListener) + removePreDrawListener(state) state.layoutListener?.let { state.root?.removeOnLayoutChangeListener(it) } state.host?.let { host -> clearHost(host) @@ -74,6 +97,13 @@ internal class OneUiCardsGridRenderer { return } val root = container.rootView as? ViewGroup ?: return failOpen(state) + if (state.config.settings.maxRows == 0) { + state.host?.let { + clearHost(it) + it.visibility = View.GONE + } + return + } val remaining = state.sources.filterNot { it.key in state.visibleCardKeys } if (remaining.isEmpty()) { failOpen(state) @@ -234,12 +264,24 @@ internal class OneUiCardsGridRenderer { private fun findNativeSurface(container: ViewGroup): View { var current = container.parent as? View while (current != null && current !== container.rootView) { - if (current.javaClass.name.contains("NotificationShelf")) return current + if (current is ViewGroup && current.javaClass.name.contains("NotificationShelf")) { + return findShelfBackground(current) ?: container + } current = current.parent as? View } return container } + private fun findShelfBackground(root: ViewGroup): View? { + val pending = ArrayDeque().apply { add(root) } + while (pending.isNotEmpty()) { + val view = pending.removeFirst() + if (view.javaClass.name.contains("NotificationShelfBackground")) return view + if (view is ViewGroup) repeat(view.childCount) { pending += view.getChildAt(it) } + } + return null + } + private fun clearHost(host: ViewGroup) { repeat(host.childCount) { index -> recycleIcon(host.getChildAt(index)) } host.removeAllViews() @@ -259,16 +301,47 @@ internal class OneUiCardsGridRenderer { if (state.nativeSurface !== native) { restoreNative(state) state.nativeSurface = native - state.nativeAlpha = native.alpha + nativeSurfaces.getOrPut(native) { NativeSurface(native.alpha) }.owners += state } + state.nativeSuppressed = true native.alpha = 0f } private fun restoreNative(state: State) { - state.nativeSurface?.alpha = state.nativeAlpha + state.nativeSuppressed = false + state.nativeSurface?.let { native -> + nativeSurfaces[native]?.let { surface -> + surface.owners -= state + if (surface.owners.isEmpty()) { + native.alpha = surface.alpha + nativeSurfaces.remove(native) + } + } + } state.nativeSurface = null } + private fun installPreDrawListener(container: ViewGroup, state: State) { + removePreDrawListener(state) + val observer = container.viewTreeObserver.takeIf(ViewTreeObserver::isAlive) ?: return + val listener = ViewTreeObserver.OnPreDrawListener { + if (state.nativeSuppressed) state.nativeSurface?.let { native -> + if (native.alpha != 0f) native.alpha = 0f + } + true + } + observer.addOnPreDrawListener(listener) + state.preDrawObserver = observer + state.preDrawListener = listener + } + + private fun removePreDrawListener(state: State) { + val listener = state.preDrawListener ?: return + state.preDrawObserver?.takeIf(ViewTreeObserver::isAlive)?.removeOnPreDrawListener(listener) + state.preDrawObserver = null + state.preDrawListener = null + } + private fun findDrawable(view: View?): Drawable? = when (view) { is ImageView -> view.drawable is ViewGroup -> (0 until view.childCount).firstNotNullOfOrNull { findDrawable(view.getChildAt(it)) } @@ -297,9 +370,10 @@ internal class OneUiCardsGridRenderer { private fun state(container: ViewGroup) = states.getOrPut(container) { State().also { state -> state.attachListener = object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(view: View) = Unit + override fun onViewAttachedToWindow(view: View) = installPreDrawListener(container, state) override fun onViewDetachedFromWindow(view: View) = release(container) }.also(container::addOnAttachStateChangeListener) + if (container.isAttachedToWindow) installPreDrawListener(container, state) } } @@ -326,11 +400,17 @@ internal class OneUiCardsGridRenderer { var attachListener: View.OnAttachStateChangeListener? = null var active = false var nativeSurface: View? = null - var nativeAlpha = 1f + var nativeSuppressed = false + var preDrawObserver: ViewTreeObserver? = null + var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null var lastDiagnosticSignature = 0 var config = Config(CardsGridSettings.LOCKSCREEN_DEFAULT, showPills = true, hideShelf = true) } + private class NativeSurface(val alpha: Float) { + val owners = mutableSetOf() + } + private companion object { const val HOST_TAG = "notifications-master:cards-grid" const val DEFAULT_PILL_COLOR = 0xAA202124.toInt() diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt index 0f02f6e..040c67c 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt @@ -32,7 +32,9 @@ internal class OneUiLockscreenCardsBackend( @Volatile private var statusBarStateKnown = false @Volatile private var statusBarState = SHADE @Volatile private var dozing = false + @Volatile private var dozeAmount = 0f @Volatile private var blockedObserved = false + @Volatile private var nativeShelfSuppressedObserved = false override fun install(classLoader: ClassLoader): Boolean = runCatching { Class.forName(ONE_UI_LOCKSCREEN_COORDINATOR, false, classLoader) @@ -85,9 +87,16 @@ internal class OneUiLockscreenCardsBackend( method.isAccessible = true framework.hook(method).intercept { chain -> val leavingLockscreen = method.name == "setState" && chain.args.firstOrNull() != KEYGUARD - val enteringDoze = method.name == "setDozeAmountInternal" && - ((chain.args.firstOrNull() as? Number)?.toFloat() ?: 0f) > 0f - if (leavingLockscreen || enteringDoze) gridRenderer.deactivateAll() + if (method.name == "setDozeAmountInternal") { + dozeAmount = ((chain.args.firstOrNull() as? Number)?.toFloat() ?: 0f) + .coerceIn(0f, 1f) + } + val enteringDoze = dozeAmount > 0f + if (enteringDoze) { + gridRenderer.refreshAll({ false }, { emptySet() }, gridConfig(), ::isCardsMode) + } else if (leavingLockscreen) { + gridRenderer.deactivateAll() + } val result = chain.proceed() updateState(chain.thisObject) result @@ -139,19 +148,29 @@ internal class OneUiLockscreenCardsBackend( calculate.isAccessible = true framework.hook(calculate).intercept { chain -> val container = chain.thisObject as? ViewGroup ?: return@intercept chain.proceed() - if (!isLockscreenCards(container)) return@intercept chain.proceed() + if (isDozeTransition() && isCardsMode(container)) { + gridRenderer.suspend(container, true) + } + if (!shouldFilterLockscreenCards(container)) return@intercept chain.proceed() val children = List(container.childCount, container::getChildAt) - val blocked = children.filter(::isBlockedShelfIcon) - if (blocked.isEmpty()) return@intercept chain.proceed() val states = shelfIconStates.get(container) as? Map<*, *> ?: return@intercept chain.proceed() + val blocked = children.filter(::isBlockedShelfIcon) blocked.forEach { icon -> states[icon]?.let(::hideShelfIconState) } children.filterNot(blocked::contains).forEach(container::bringChildToFront) - try { + val result = try { chain.proceed() } finally { children.forEach(container::bringChildToFront) } + // OneUI applies these states after this calculation returns. Suppress every + // stock shelf icon in Cards mode; the renderer owns the supplemental grid. + children.forEach { icon -> states[icon]?.let(::hideShelfIconState) } + if (!nativeShelfSuppressedObserved) { + nativeShelfSuppressedObserved = true + Log.i(TAG, "Suppressed the native OneUI Cards shelf icon states") + } + result } } @@ -187,12 +206,11 @@ internal class OneUiLockscreenCardsBackend( val result = chain.proceed(arguments) gridRenderer.updateSources(container, sources) val active = isLockscreenCards(container) - gridRenderer.refresh( - container, - active, - if (active) visibleCardKeys(container) else emptySet(), - gridConfig(), - ) + if (active) { + gridRenderer.refresh(container, true, visibleCardKeys(container), gridConfig()) + } else { + gridRenderer.suspend(container, isDozeTransition() && isCardsMode(container)) + } result } } @@ -210,17 +228,20 @@ internal class OneUiLockscreenCardsBackend( statusBarStateKnown = true } (findField(controller.javaClass, "mIsDozing")?.get(controller) as? Boolean)?.let { dozing = it } - if (dozing || statusBarState != KEYGUARD) gridRenderer.deactivateAll() applyTrackedStacks() } private fun applyTrackedStacks() { synchronized(stacks) { stacks.toList() }.forEach(::applyStack) - gridRenderer.refreshAll(::isLockscreenCards, ::visibleCardKeys, gridConfig()) + gridRenderer.refreshAll( + ::isLockscreenCards, + ::visibleCardKeys, + gridConfig(), + ) { isDozeTransition() && isCardsMode(it) } } private fun applyStack(stack: ViewGroup) { - val active = isLockscreenCards(stack) + val active = shouldFilterLockscreenCards(stack) repeat(stack.childCount) { index -> val row = stack.getChildAt(index) if (row.javaClass.name == ROW) { @@ -231,12 +252,17 @@ internal class OneUiLockscreenCardsBackend( private fun applyRow(row: View) { val stack = row.parent as? ViewGroup ?: return - val active = isLockscreenCards(stack) + val active = shouldFilterLockscreenCards(stack) if (active && isBlocked(row)) hideRow(row) else restoreRow(row) } private fun isLockscreenCards(view: View) = - statusBarStateKnown && statusBarState == KEYGUARD && !dozing && isCardsMode(view) + shouldFilterLockscreenCards(view) && !isDozeTransition() + + private fun shouldFilterLockscreenCards(view: View) = + statusBarStateKnown && statusBarState == KEYGUARD && isCardsMode(view) + + private fun isDozeTransition() = dozing || dozeAmount > 0f private fun isCardsMode(view: View) = runCatching { Settings.System.getInt(view.context.contentResolver, LOCKSCREEN_STYLE) == CARDS @@ -303,8 +329,6 @@ internal class OneUiLockscreenCardsBackend( } private fun visibleCardKeys(container: ViewGroup): Set { - val shelfLocation = IntArray(2) - runCatching { container.getLocationOnScreen(shelfLocation) }.getOrElse { return emptySet() } val root = container.rootView return synchronized(stacks) { stacks.toList() } .asSequence() @@ -314,10 +338,6 @@ internal class OneUiLockscreenCardsBackend( row.javaClass.name == ROW && row.visibility == View.VISIBLE && row.alpha > 0.05f && !booleanField(row, "mInShelf") && !booleanField(row, "mTransformingInShelf") } - .filter { row -> - val location = IntArray(2) - runCatching { row.getLocationOnScreen(location) }.isSuccess && location[1] < shelfLocation[1] - } .mapNotNull { row -> runCatching { entryForRow(row)?.let(sbnForEntry)?.key }.getOrNull() } .toSet() } 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 cad8377..a5d62c7 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt @@ -20,7 +20,7 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib private val managers = Collections.synchronizedMap(WeakHashMap()) private val cardsContainers = Collections.newSetFromMap(WeakHashMap()) private val cardsDozing = Collections.synchronizedMap(WeakHashMap()) - private val cardsReadyAt = 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()) @@ -107,18 +107,19 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib framework.hook(method).intercept { chain -> val container = chain.thisObject as? ViewGroup val dozing = chain.args.firstOrNull() == true + val origin = container?.let(::screenLocation) container?.let { trackCardsContainer(it) cardsDozing[it] = false - cardsRenderer.deactivate(it) + cardsRenderer.suspend(it, isCardsMode(it), it) } val result = chain.proceed() container?.let { view -> cardsDozing[view] = dozing - if (dozing) settleCardsGrid(view, DOZE_SETTLE_DELAY) else { - cardsReadyAt.remove(view) + if (dozing) beginDozeTransition(view, origin ?: screenLocation(view)) else { + cardsTransitions.remove(view) cardsLocations.remove(view) - refreshCardsGrid() + cardsRenderer.suspend(view, isCardsMode(view), view) } } result @@ -146,37 +147,56 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib } container.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(view: View) { - if (cardsDozing[container] == true) settleCardsGrid(container) else refreshCardsGrid() + if (cardsDozing[container] == true) beginLayoutSettle(container) else refreshCardsGrid() } override fun onViewDetachedFromWindow(view: View) { cardsDozing[container] = false - cardsReadyAt.remove(container) + 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) settleCardsGrid(container) else refreshCardsGrid() + if (cardsDozing[container] == true) { + if (cardsTransitions[container] == null) beginLayoutSettle(container) else refreshCardsGrid() + } else refreshCardsGrid() } } refreshCardsGrid() } - private fun settleCardsGrid(container: ViewGroup, delay: Long = LAYOUT_SETTLE_DELAY) { - cardsRenderer.deactivate(container) - cardsReadyAt[container] = SystemClock.uptimeMillis() + delay - refreshCardsGrid() + private fun beginDozeTransition(container: ViewGroup, origin: Pair) { + val now = SystemClock.uptimeMillis() + val current = screenLocation(container) + cardsTransitions[container] = CardsTransition( + origin, + current, + current != origin, + now, + now + TRANSITION_FALLBACK_DELAY, + ) + cardsRenderer.suspend(container, isCardsMode(container), container) + refreshCardsGrid(POSITION_POLL_DELAY) } - private fun refreshCardsGrid() { - cardsRefreshTask?.let(mainHandler::removeCallbacks) + private fun beginLayoutSettle(container: ViewGroup) { val now = SystemClock.uptimeMillis() - val delay = synchronized(cardsReadyAt) { - cardsReadyAt.entries - .filter { cardsDozing[it.key] == true } - .maxOfOrNull { (it.value - now).coerceAtLeast(0) } ?: 0 - } + val current = screenLocation(container) + val origin = cardsLocations[container] ?: current + cardsTransitions[container] = CardsTransition( + origin, + current, + moved = true, + stableSince = now, + deadline = now + TRANSITION_FALLBACK_DELAY, + ) + cardsRenderer.suspend(container, isCardsMode(container), container) + refreshCardsGrid(POSITION_POLL_DELAY) + } + + private fun refreshCardsGrid(delay: Long = 0L) { + cardsRefreshTask?.let(mainHandler::removeCallbacks) cardsRefreshTask = Runnable { cardsRefreshTask = null renderCardsGrid() @@ -184,23 +204,53 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib } private fun renderCardsGrid() { - val moving = synchronized(cardsContainers) { cardsContainers.toList() }.filter { container -> - if (cardsDozing[container] != true) return@filter false + val ready = ArrayList() + var pollAgain = false + synchronized(cardsContainers) { cardsContainers.toList() }.forEach { container -> + if (!container.isAttachedToWindow) return@forEach + if (!isCardsMode(container)) { + cardsTransitions.remove(container) + cardsRenderer.deactivate(container) + return@forEach + } + if (cardsDozing[container] != true) { + cardsTransitions.remove(container) + cardsRenderer.suspend(container, true, container) + return@forEach + } val location = screenLocation(container) - val previous = cardsLocations.put(container, location) - previous == null || previous != location - } - if (moving.isNotEmpty()) { - moving.forEach { settleCardsGrid(it) } - return + val transition = cardsTransitions[container] + if (transition == null) { + cardsLocations[container] = location + ready += container + return@forEach + } + cardsRenderer.suspend(container, true, container) + val now = SystemClock.uptimeMillis() + if (location != transition.lastLocation) { + transition.moved = transition.moved || location != transition.origin + transition.lastLocation = location + transition.stableSince = now + } + if (transition.moved && now - transition.stableSince >= POSITION_STABLE_DELAY || + now >= transition.deadline + ) { + cardsTransitions.remove(container) + cardsLocations[container] = location + ready += container + } else { + pollAgain = true + } } + 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, ) - synchronized(cardsContainers) { cardsContainers.toList() }.forEach { container -> + ready.forEach { container -> val tint = runCatching { container.javaClass.getMethod("getIconColor").invoke(container) as Int }.getOrNull() @@ -219,7 +269,7 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib cardsRenderer.updateSources(container, sources) cardsRenderer.refresh( container, - container.isAttachedToWindow && cardsDozing[container] == true && isCardsMode(container), + active = true, emptySet(), config, ) @@ -288,11 +338,20 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib val type: Int, ) + private data class CardsTransition( + val origin: Pair, + var lastLocation: Pair, + var moved: Boolean, + var stableSince: Long, + val deadline: Long, + ) + private companion object { const val TAG = "NotificationsMaster" const val CARDS = 0 - const val LAYOUT_SETTLE_DELAY = 120L - const val DOZE_SETTLE_DELAY = 220L + const val POSITION_POLL_DELAY = 80L + const val POSITION_STABLE_DELAY = 180L + const val TRANSITION_FALLBACK_DELAY = 2_500L const val NOTIFICATION_STYLE = "lockscreen_minimizing_notification" const val MANAGER = "com.samsung.android.uniform.plugins.notification.AODNotificationManager" const val AOD_ICON_CONTAINER =