From b7d6bb6d632deba7bc07839f8bb54e7092deb47b Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sat, 29 Aug 2026 23:22:11 +0000 Subject: [PATCH] Render OneUI AOD Cards notification grid --- .../module/SamsungAodBackend.kt | 116 +++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) 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 0742356..b8f864f 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt @@ -2,8 +2,11 @@ package se.ajpanton.notificationsmaster.module import android.os.Handler import android.os.Looper +import android.provider.Settings import android.service.notification.StatusBarNotification import android.util.Log +import android.view.View +import android.view.ViewGroup import io.github.libxposed.api.XposedInterface import se.ajpanton.notificationsmaster.visibility.NotificationSurface import java.lang.reflect.Method @@ -13,10 +16,16 @@ import java.util.WeakHashMap /** Filters the notification lists handed to Samsung's AOD renderer. */ internal class SamsungAodBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend { private val managers = Collections.synchronizedMap(WeakHashMap()) + private val cardsContainers = Collections.newSetFromMap(WeakHashMap()) + private val cardsDozing = 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 cardsRefreshPending = false override fun install(classLoader: ClassLoader): Boolean = runCatching { val manager = Class.forName(MANAGER, false, classLoader) @@ -27,6 +36,7 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib List::class.java, Int::class.javaPrimitiveType!!, ) + installCardsGrid(classLoader) framework.hook(updateActive).intercept { chain -> val raw = notificationList(chain.args[0]) ?: return@intercept chain.proceed() if (refreshing.get() != true) state(chain.thisObject).active = raw @@ -40,9 +50,12 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib } val filteredFirst = filter(first) val filteredSecond = filter(second) - if (filteredFirst === first && filteredSecond === second) chain.proceed() else { + visibleNotifications = filteredSecond + val result = if (filteredFirst === first && filteredSecond === second) chain.proceed() else { chain.proceed(arrayOf(filteredFirst, filteredSecond, chain.args[2])) } + refreshCardsGrid() + result } }.onSuccess { Log.i(TAG, "Installed Samsung AOD notification backend") @@ -52,7 +65,7 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib override fun onPolicyChanged() { blockedObserved = false - Handler(Looper.getMainLooper()).post { + mainHandler.post { val current = synchronized(managers) { managers.entries.map { it.key to it.value.copy() } } refreshing.set(true) try { @@ -67,9 +80,104 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib } finally { refreshing.remove() } + refreshCardsGrid() } } + private fun installCardsGrid(classLoader: ClassLoader) { + runCatching { + val container = Class.forName(AOD_ICON_CONTAINER, false, classLoader) + container.declaredConstructors.forEach { constructor -> + constructor.isAccessible = true + framework.hook(constructor).intercept { chain -> + val result = chain.proceed() + (chain.thisObject as? ViewGroup)?.let(::trackCardsContainer) + result + } + } + container.declaredMethods.filter { + it.name == "setDozing" && it.parameterCount == 1 && + it.parameterTypes[0] == Boolean::class.javaPrimitiveType + }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + val result = chain.proceed() + (chain.thisObject as? ViewGroup)?.let { view -> + cardsDozing[view] = chain.args.firstOrNull() == true + refreshCardsGrid() + } + result + } + } + container.declaredMethods.filter { + (it.name == "setIconColor" || it.name == "setIconSize") && it.parameterCount == 1 + }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept { chain -> + val result = chain.proceed() + refreshCardsGrid() + result + } + } + }.onFailure { error -> + Log.d(TAG, "Samsung AOD Cards grid boundary is unavailable", error) + } + } + + private fun trackCardsContainer(container: ViewGroup) { + synchronized(cardsContainers) { + if (!cardsContainers.add(container)) return + } + container.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(view: View) = refreshCardsGrid() + override fun onViewDetachedFromWindow(view: View) { + cardsDozing[container] = false + cardsRenderer.release(container) + } + }) + container.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> refreshCardsGrid() } + refreshCardsGrid() + } + + private fun refreshCardsGrid() { + if (cardsRefreshPending) return + cardsRefreshPending = true + mainHandler.post { + cardsRefreshPending = false + val notifications = visibleNotifications.distinctBy(StatusBarNotification::getKey) + val config = OneUiCardsGridRenderer.Config( + ProcessVisibilityPolicyCache.aodCards, + showPills = false, + hideShelf = false, + ) + synchronized(cardsContainers) { cardsContainers.toList() }.forEach { container -> + val tint = runCatching { + container.javaClass.getMethod("getIconColor").invoke(container) as Int + }.getOrNull() + val sources = notifications.map { sbn -> + OneUiCardsGridRenderer.Source( + sbn.key, + view = null, + sbn.notification.smallIcon, + sbn.packageName, + tint, + ) + } + cardsRenderer.updateSources(container, sources) + cardsRenderer.refresh( + container, + container.isAttachedToWindow && cardsDozing[container] == true && isCardsMode(container), + emptySet(), + config, + ) + } + } + } + + private fun isCardsMode(view: View) = runCatching { + Settings.System.getInt(view.context.contentResolver, NOTIFICATION_STYLE) == CARDS + }.getOrDefault(false) + private fun proceedFiltered( chain: XposedInterface.Chain, argument: Int, @@ -124,6 +232,10 @@ internal class SamsungAodBackend(private val framework: XposedInterface) : Visib private companion object { const val TAG = "NotificationsMaster" + const val CARDS = 0 + const val NOTIFICATION_STYLE = "lockscreen_minimizing_notification" const val MANAGER = "com.samsung.android.uniform.plugins.notification.AODNotificationManager" + const val AOD_ICON_CONTAINER = + "com.samsung.android.uniform.widget.notification.NotificationIconsOnlyContainer" } }