Own OneUI Cards shelves during transitions

This commit is contained in:
ajp_anton
2026-08-30 12:11:35 +00:00
parent 0a0e7c7649
commit 192bdb5381
3 changed files with 224 additions and 65 deletions
@@ -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<ViewGroup, State>()
private val nativeSurfaces = WeakHashMap<View, NativeSurface>()
fun updateSources(container: ViewGroup, sources: List<Source>) {
val unique = LinkedHashMap<String, Source>()
@@ -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<String>,
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<View>().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<State>()
}
private companion object {
const val HOST_TAG = "notifications-master:cards-grid"
const val DEFAULT_PILL_COLOR = 0xAA202124.toInt()
@@ -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<String> {
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()
}
@@ -20,7 +20,7 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib
private val managers = Collections.synchronizedMap(WeakHashMap<Any, State>())
private val cardsContainers = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val cardsDozing = Collections.synchronizedMap(WeakHashMap<ViewGroup, Boolean>())
private val cardsReadyAt = Collections.synchronizedMap(WeakHashMap<ViewGroup, Long>())
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())
@@ -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<Int, Int>) {
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<ViewGroup>()
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<Int, Int>,
var lastLocation: Pair<Int, Int>,
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 =