From 568b9a9549f597ba4cecb65c171452680cd29949 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sat, 29 Aug 2026 23:08:59 +0000 Subject: [PATCH] Render OneUI lockscreen Cards overflow grid --- .../module/OneUiCardsGridRenderer.kt | 307 ++++++++++++++++++ .../module/OneUiLockscreenCardsBackend.kt | 78 +++++ .../include_cards_grid_settings_aod.xml | 4 +- ...include_cards_grid_settings_lockscreen.xml | 4 +- test-helper/build.gradle.kts | 7 +- test-helper/src/main/AndroidManifest.xml | 2 +- .../helper/MainActivity.kt | 5 + 7 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt new file mode 100644 index 0000000..0d9293a --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiCardsGridRenderer.kt @@ -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() + + fun updateSources(container: ViewGroup, sources: List) { + val unique = LinkedHashMap() + sources.forEach { unique.putIfAbsent(it.key, it) } + state(container).sources = unique.values.toList() + } + + fun refresh(container: ViewGroup, active: Boolean, visibleCardKeys: Set, 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, + 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? = 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() + var visibleCardKeys = emptySet() + 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" + } +} 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 c192564..e701dc2 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenCardsBackend.kt @@ -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()) private val hiddenRows = WeakHashMap() + 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 + val sources = ArrayList() + val wrapped = Function { 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 { + 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" } diff --git a/app/src/main/res/layout/include_cards_grid_settings_aod.xml b/app/src/main/res/layout/include_cards_grid_settings_aod.xml index bed870e..f986a57 100644 --- a/app/src/main/res/layout/include_cards_grid_settings_aod.xml +++ b/app/src/main/res/layout/include_cards_grid_settings_aod.xml @@ -5,9 +5,9 @@ android:orientation="vertical"> - + - + diff --git a/app/src/main/res/layout/include_cards_grid_settings_lockscreen.xml b/app/src/main/res/layout/include_cards_grid_settings_lockscreen.xml index bed870e..f986a57 100644 --- a/app/src/main/res/layout/include_cards_grid_settings_lockscreen.xml +++ b/app/src/main/res/layout/include_cards_grid_settings_lockscreen.xml @@ -5,9 +5,9 @@ android:orientation="vertical"> - + - + diff --git a/test-helper/build.gradle.kts b/test-helper/build.gradle.kts index 0a87428..4e39c3b 100644 --- a/test-helper/build.gradle.kts +++ b/test-helper/build.gradle.kts @@ -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) } } } diff --git a/test-helper/src/main/AndroidManifest.xml b/test-helper/src/main/AndroidManifest.xml index 7af0472..4f4c3c0 100644 --- a/test-helper/src/main/AndroidManifest.xml +++ b/test-helper/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt index 43185bd..8900255 100644 --- a/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt +++ b/test-helper/src/main/java/se/ajpanton/notificationsmaster/helper/MainActivity.kt @@ -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