Render OneUI lockscreen Cards overflow grid

This commit is contained in:
ajp_anton
2026-08-29 23:08:59 +00:00
parent ae1bf8e62b
commit 568b9a9549
7 changed files with 401 additions and 6 deletions
@@ -0,0 +1,307 @@
package se.ajpanton.notificationsmaster.module
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import se.ajpanton.notificationsmaster.visibility.CardsGridLayout
import se.ajpanton.notificationsmaster.visibility.CardsGridSettings
import java.util.LinkedHashMap
import java.util.WeakHashMap
/** Renders a OneUI Cards-mode icon grid from notification icon snapshots. */
internal class OneUiCardsGridRenderer {
private val states = WeakHashMap<ViewGroup, State>()
fun updateSources(container: ViewGroup, sources: List<Source>) {
val unique = LinkedHashMap<String, Source>()
sources.forEach { unique.putIfAbsent(it.key, it) }
state(container).sources = unique.values.toList()
}
fun refresh(container: ViewGroup, active: Boolean, visibleCardKeys: Set<String>, config: Config) {
val state = state(container)
state.active = active
state.visibleCardKeys = visibleCardKeys
state.config = config
container.post { render(container, state, state.active) }
}
fun refreshAll(
active: (ViewGroup) -> Boolean,
visibleCardKeys: (ViewGroup) -> Set<String>,
config: Config,
) {
states.keys.toList().forEach { container ->
refresh(container, active(container), visibleCardKeys(container), config)
}
}
private fun render(container: ViewGroup, state: State, active: Boolean) {
if (!active || !container.isAttachedToWindow) {
failOpen(state)
return
}
val root = container.rootView as? ViewGroup ?: return failOpen(state)
val remaining = state.sources.filterNot { it.key in state.visibleCardKeys }
if (remaining.isEmpty()) {
failOpen(state)
return
}
val host = attachHost(root, container, state) ?: return failOpen(state)
val iconSize = iconSize(root, container, state.config.settings)
val settings = state.config.settings
val horizontalPadding = dp(root, 8)
val widthCapacity = if (iconSize > 0) {
(root.width - horizontalPadding * 2).coerceAtLeast(0) / iconSize
} else 0
val rows = CardsGridLayout.arrange(remaining.size, settings, widthCapacity)
val location = relativeLocation(container, root) ?: return failOpen(state)
if (root.width <= 0 || rows.visibleSlots == 0) return failOpen(state)
val iconCount = rows.visibleSlots - if (rows.showsOverflow) 1 else 0
val icons = remaining.take(iconCount).map { notificationIcon(root, it, iconSize) }
if (icons.any { it == null }) {
icons.filterNotNull().forEach(::recycleIcon)
return failOpen(state)
}
clearHost(host)
host.visibility = View.VISIBLE
val rowGap = dp(root, 8)
val nativeSurface = if (state.config.hideShelf) findNativeSurface(container) else container
if (nativeSurface === root) return failOpen(state)
var sourceIndex = 0
var slotIndex = 0
rows.rowSizes.forEachIndexed { row, count ->
val rowWidth = count * iconSize
val left = (location.first + container.width / 2 - rowWidth / 2).let {
if (settings.limitToScreenWidth) {
it.coerceIn(horizontalPadding, (root.width - rowWidth - horizontalPadding).coerceAtLeast(horizontalPadding))
} else it
}
val top = location.second + row * (iconSize + rowGap)
if (state.config.showPills) {
host.addView(pill(root, nativeSurface, left, top, rowWidth, iconSize))
}
repeat(count) { column ->
val overflow = rows.showsOverflow && slotIndex == rows.visibleSlots - 1
val icon = if (overflow) overflowDot(root, iconSize) else icons[sourceIndex++]!!
host.addView(icon, frame(iconSize, iconSize, left + column * iconSize, top))
slotIndex++
}
}
hideNative(state, nativeSurface)
val diagnosticSignature = listOf(remaining.size, state.visibleCardKeys.size, rows.visibleSlots,
settings.maxRows, settings.maxIconsPerRow, iconSize).hashCode()
if (diagnosticSignature != state.lastDiagnosticSignature) {
state.lastDiagnosticSignature = diagnosticSignature
Log.i(TAG, "Rendered OneUI Cards grid: cards=${state.visibleCardKeys.size} " +
"remaining=${remaining.size} slots=${rows.visibleSlots} overflow=${rows.showsOverflow}")
}
}
private fun attachHost(root: ViewGroup, container: ViewGroup, state: State): FrameLayout? {
state.host?.takeIf { it.parent === root }?.let { return it }
state.layoutListener?.let { listener -> state.root?.removeOnLayoutChangeListener(listener) }
state.host?.let { (it.parent as? ViewGroup)?.removeView(it) }
return runCatching {
FrameLayout(root.context).apply {
tag = HOST_TAG
clipChildren = false
clipToPadding = false
isClickable = false
isFocusable = false
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
root.addView(this, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
))
state.host = this
state.root = root
val listener = View.OnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
refresh(container, state.active, state.visibleCardKeys, state.config)
}
}
state.layoutListener = listener
root.addOnLayoutChangeListener(listener)
}
}.getOrNull()
}
private fun notificationIcon(root: View, source: Source, size: Int): ImageView? {
val drawable = findDrawable(source.view)
?: runCatching {
val context = source.packageName?.let { root.context.createPackageContext(it, 0) }
?: root.context
source.smallIcon?.loadDrawable(context)
}.getOrNull()
?: return null
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val bounds = Rect(drawable.bounds)
try {
drawable.bounds = aspectFit(drawable, bounds, size)
drawable.draw(Canvas(bitmap))
} finally {
drawable.bounds = bounds
}
return ImageView(root.context).apply {
setImageBitmap(bitmap)
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
}
private fun overflowDot(root: View, size: Int) = View(root.context).apply {
background = object : GradientDrawable() {
override fun draw(canvas: Canvas) {
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
val radius = size * 0.125f
canvas.drawCircle(size / 2f, size / 2f, radius, paint)
}
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
private fun pill(root: View, native: View, left: Int, top: Int, width: Int, height: Int): View {
val horizontal = dp(root, 8)
val vertical = dp(root, 3)
return View(root.context).apply {
background = native.background?.constantState?.newDrawable(root.resources)?.mutate()
?: GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = (height + vertical * 2) / 2f
setColor(DEFAULT_PILL_COLOR)
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
layoutParams = frame(
width + horizontal * 2,
height + vertical * 2,
left - horizontal,
top - vertical,
)
}
}
private fun iconSize(root: View, source: View, settings: CardsGridSettings): Int {
val id = root.resources.getIdentifier("status_bar_height", "dimen", "android")
val statusBarHeight = if (id != 0) root.resources.getDimensionPixelSize(id) else 0
val fallback = minOf(source.width, source.height).coerceAtLeast(1)
return if (statusBarHeight > 0) {
(statusBarHeight * settings.iconHeightPercent / 100f).toInt().coerceAtLeast(1)
} else fallback
}
private fun relativeLocation(view: View, root: View): Pair<Int, Int>? = runCatching {
val viewLocation = IntArray(2)
val rootLocation = IntArray(2)
view.getLocationOnScreen(viewLocation)
root.getLocationOnScreen(rootLocation)
viewLocation[0] - rootLocation[0] to viewLocation[1] - rootLocation[1]
}.getOrNull()
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
current = current.parent as? View
}
return container
}
private fun clearHost(host: ViewGroup) {
repeat(host.childCount) { index -> recycleIcon(host.getChildAt(index)) }
host.removeAllViews()
}
private fun recycleIcon(view: View) {
((view as? ImageView)?.drawable as? BitmapDrawable)?.bitmap
?.takeUnless(Bitmap::isRecycled)?.recycle()
}
private fun failOpen(state: State) {
restoreNative(state)
state.host?.visibility = View.GONE
}
private fun hideNative(state: State, native: View) {
if (state.nativeSurface !== native) {
restoreNative(state)
state.nativeSurface = native
state.nativeAlpha = native.alpha
}
native.alpha = 0f
}
private fun restoreNative(state: State) {
state.nativeSurface?.alpha = state.nativeAlpha
state.nativeSurface = null
}
private fun findDrawable(view: View?): Drawable? = when (view) {
is ImageView -> view.drawable
is ViewGroup -> (0 until view.childCount).firstNotNullOfOrNull { findDrawable(view.getChildAt(it)) }
else -> null
}
private fun aspectFit(drawable: Drawable, fallback: Rect, size: Int): Rect {
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: fallback.width().coerceAtLeast(1)
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: fallback.height().coerceAtLeast(1)
val scale = minOf(size.toFloat() / width, size.toFloat() / height)
val targetWidth = (width * scale).toInt().coerceAtLeast(1)
val targetHeight = (height * scale).toInt().coerceAtLeast(1)
val left = (size - targetWidth) / 2
val top = (size - targetHeight) / 2
return Rect(left, top, left + targetWidth, top + targetHeight)
}
private fun frame(width: Int, height: Int, left: Int, top: Int) =
FrameLayout.LayoutParams(width, height).apply {
leftMargin = left
topMargin = top
}
private fun dp(view: View, value: Int) = (value * view.resources.displayMetrics.density).toInt()
private fun state(container: ViewGroup) = states.getOrPut(container, ::State)
data class Source(
val key: String,
val view: View?,
val smallIcon: android.graphics.drawable.Icon?,
val packageName: String? = null,
)
data class Config(
val settings: CardsGridSettings,
val showPills: Boolean,
val hideShelf: Boolean,
)
private class State {
var sources = emptyList<Source>()
var visibleCardKeys = emptySet<String>()
var host: FrameLayout? = null
var root: ViewGroup? = null
var layoutListener: View.OnLayoutChangeListener? = null
var active = false
var nativeSurface: View? = null
var nativeAlpha = 1f
var lastDiagnosticSignature = 0
var config = Config(CardsGridSettings.LOCKSCREEN_DEFAULT, showPills = true, hideShelf = true)
}
private companion object {
const val HOST_TAG = "notifications-master:cards-grid"
const val DEFAULT_PILL_COLOR = 0xAA202124.toInt()
const val TAG = "NotificationsMaster"
}
}
@@ -11,6 +11,7 @@ import java.lang.reflect.Field
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
import java.util.function.Function
/** Hides blocked OneUI notification rows only on the collapsed cards lockscreen. */
internal class OneUiLockscreenCardsBackend(
@@ -18,6 +19,7 @@ internal class OneUiLockscreenCardsBackend(
) : VisibilitySurfaceBackend {
private val stacks = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val hiddenRows = WeakHashMap<View, RowState>()
private val gridRenderer = OneUiCardsGridRenderer()
private lateinit var entryForRow: (View) -> Any?
private lateinit var sbnForEntry: (Any) -> StatusBarNotification?
private lateinit var shelfNotification: Field
@@ -39,6 +41,7 @@ internal class OneUiLockscreenCardsBackend(
val stateController = Class.forName(STATE_CONTROLLER, false, classLoader)
prepareNotificationAccess(rowClass)
installShelfFilter(classLoader)
installGridSourceHook(classLoader)
stackClass.declaredMethods.filter { it.name == "onViewAdded" }.forEach { method ->
method.isAccessible = true
@@ -148,6 +151,49 @@ internal class OneUiLockscreenCardsBackend(
}
}
private fun installGridSourceHook(classLoader: ClassLoader) {
val controller = Class.forName(ICON_AREA_CONTROLLER, false, classLoader)
val shelfClass = Class.forName(SHELF_ICONS, false, classLoader)
controller.declaredMethods.filter { it.name == "updateIconsForLayout" }.forEach { method ->
method.isAccessible = true
framework.hook(method).intercept { chain ->
val functionIndex = chain.args.indexOfFirst { it is Function<*, *> }
val container = chain.args.firstOrNull { shelfClass.isInstance(it) } as? ViewGroup
if (functionIndex < 0 || container == null) {
return@intercept chain.proceed()
}
@Suppress("UNCHECKED_CAST")
val original = chain.args[functionIndex] as Function<Any?, Any?>
val sources = ArrayList<OneUiCardsGridRenderer.Source>()
val wrapped = Function<Any?, Any?> { entry ->
val result = original.apply(entry)
val sbn = entry?.let { runCatching { sbnForEntry(it) }.getOrNull() }
if (result is View && sbn != null && !isBlocked(sbn)) {
sources += OneUiCardsGridRenderer.Source(
sbn.key,
result,
sbn.notification.smallIcon,
sbn.packageName,
)
}
result
}
val arguments = chain.args.toTypedArray()
arguments[functionIndex] = wrapped
val result = chain.proceed(arguments)
gridRenderer.updateSources(container, sources)
val active = isLockscreenCards(container)
gridRenderer.refresh(
container,
active,
if (active) visibleCardKeys(container) else emptySet(),
gridConfig(),
)
result
}
}
}
private fun accessor(method: Method?, field: Field?): (Any) -> Any? {
method?.isAccessible = true
field?.isAccessible = true
@@ -165,6 +211,7 @@ internal class OneUiLockscreenCardsBackend(
private fun applyTrackedStacks() {
synchronized(stacks) { stacks.toList() }.forEach(::applyStack)
gridRenderer.refreshAll(::isLockscreenCards, ::visibleCardKeys, gridConfig())
}
private fun applyStack(stack: ViewGroup) {
@@ -250,6 +297,29 @@ 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()
.filter { it.rootView === root }
.flatMap { stack -> (0 until stack.childCount).asSequence().map(stack::getChildAt) }
.filter { row ->
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()
}
private fun booleanField(target: Any, name: String) =
runCatching { findField(target.javaClass, name)?.getBoolean(target) ?: false }.getOrDefault(false)
private fun requireField(type: Class<*>, name: String) =
findField(type, name) ?: error("OneUI $name field is unavailable")
@@ -267,6 +337,12 @@ internal class OneUiLockscreenCardsBackend(
return null
}
private fun gridConfig() = OneUiCardsGridRenderer.Config(
ProcessVisibilityPolicyCache.lockscreenCards,
showPills = true,
hideShelf = true,
)
private data class RowState(val visibility: Int, val alpha: Float)
private companion object {
@@ -282,6 +358,8 @@ internal class OneUiLockscreenCardsBackend(
const val SHELF_ICONS = "com.android.systemui.statusbar.phone.SecShelfNotificationIconContainer"
const val STATUS_BAR_ICON_VIEW = "com.android.systemui.statusbar.StatusBarIconView"
const val ICON_STATE = "com.android.systemui.statusbar.phone.NotificationIconContainer\$IconState"
const val ICON_AREA_CONTROLLER =
"com.android.systemui.statusbar.phone.LegacyNotificationIconAreaControllerImpl"
const val ONE_UI_LOCKSCREEN_COORDINATOR =
"com.android.systemui.statusbar.notification.collection.coordinator.LockScreenNotiIconCoordinator"
}
@@ -5,9 +5,9 @@
android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Max rows" />
<Spinner android:id="@+id/max_rows" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<Spinner android:id="@+id/max_rows" android:layout_width="wrap_content" android:layout_height="wrap_content" android:popupBackground="@drawable/dropdown_popup_background" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Max icons per row" />
<Spinner android:id="@+id/max_icons" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<Spinner android:id="@+id/max_icons" android:layout_width="wrap_content" android:layout_height="wrap_content" android:popupBackground="@drawable/dropdown_popup_background" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/even_distribution" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Even distribution" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/limit_width" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Limit to screen width" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:gravity="center_vertical" android:orientation="horizontal">
@@ -5,9 +5,9 @@
android:orientation="vertical">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Max rows" />
<Spinner android:id="@+id/max_rows" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<Spinner android:id="@+id/max_rows" android:layout_width="wrap_content" android:layout_height="wrap_content" android:popupBackground="@drawable/dropdown_popup_background" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Max icons per row" />
<Spinner android:id="@+id/max_icons" android:layout_width="wrap_content" android:layout_height="wrap_content" />
<Spinner android:id="@+id/max_icons" android:layout_width="wrap_content" android:layout_height="wrap_content" android:popupBackground="@drawable/dropdown_popup_background" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/even_distribution" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Even distribution" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/limit_width" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Limit to screen width" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:gravity="center_vertical" android:orientation="horizontal">
+6 -1
View File
@@ -1,6 +1,11 @@
plugins { alias(libs.plugins.android.application); alias(libs.plugins.kotlin.android) }
android { namespace = "se.ajpanton.notificationsmaster.helper"; compileSdk = 36
defaultConfig { applicationId = "se.ajpanton.notificationsmaster.helper"; minSdk = 34; targetSdk = 36; versionCode = 1; versionName = "0.1" }
defaultConfig {
applicationId = providers.gradleProperty("testHelperApplicationId")
.getOrElse("se.ajpanton.notificationsmaster.helper")
manifestPlaceholders["testHelperLabel"] = applicationId!!
minSdk = 34; targetSdk = 36; versionCode = 1; versionName = "0.1"
}
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
}
+1 -1
View File
@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application android:theme="@style/Theme.AppCompat.DayNight">
<application android:label="${testHelperLabel}" android:theme="@style/Theme.AppCompat.DayNight">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -1,5 +1,6 @@
package se.ajpanton.notificationsmaster.helper
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -9,6 +10,7 @@ import android.graphics.Bitmap
import android.media.AudioAttributes
import android.media.RingtoneManager
import android.os.Bundle
import android.content.pm.PackageManager
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
@@ -33,6 +35,9 @@ class MainActivity : AppCompatActivity() {
vibrationPattern = longArrayOf(0, 200)
},
)
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
setContentView(ScrollView(this).apply {
addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL