From e814a1aab3603a8d25077eebc07408b334d5f61c Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 30 Aug 2026 01:40:05 +0000 Subject: [PATCH] Keep debug notifications independent --- .../debug/DebugNotifications.kt | 4 +- .../DebugNotificationAutoGroupBridge.kt | 119 ++++++++++++++++++ .../module/NotificationsMasterModule.kt | 3 + 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/DebugNotificationAutoGroupBridge.kt diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/debug/DebugNotifications.kt b/app/src/main/java/se/ajpanton/notificationsmaster/debug/DebugNotifications.kt index 048c8a9..8b4fc5e 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/debug/DebugNotifications.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/debug/DebugNotifications.kt @@ -151,7 +151,9 @@ object DebugNotifications { private fun cancelAbove(context: Context, manager: NotificationManager, desired: Int) { manager.activeNotifications.filter { it.packageName == context.packageName }.forEach { sbn -> val number = sbn.id - ID_BASE - if (number in 1..MAX_COUNT && number > desired) cancel(manager, sbn) + val autoSummary = !isDebug(sbn.notification) && + sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 + if (autoSummary || number in 1..MAX_COUNT && number > desired) cancel(manager, sbn) } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/DebugNotificationAutoGroupBridge.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/DebugNotificationAutoGroupBridge.kt new file mode 100644 index 0000000..49cc4a9 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/DebugNotificationAutoGroupBridge.kt @@ -0,0 +1,119 @@ +package se.ajpanton.notificationsmaster.module + +import android.app.Notification +import android.service.notification.StatusBarNotification +import android.util.Log +import io.github.libxposed.api.XposedInterface +import java.lang.reflect.Method + +/** Prevents Android from collapsing this app's marked layout-test notifications. */ +internal class DebugNotificationAutoGroupBridge(private val framework: XposedInterface) { + fun install(classLoader: ClassLoader) { + val groupHelper = findClass(GROUP_HELPER, classLoader) ?: return unavailable() + val recordClass = findClass(NOTIFICATION_RECORD, classLoader) ?: return unavailable() + val notificationManager = findClass(NOTIFICATION_MANAGER, classLoader) ?: return unavailable() + + val blockGrouping = XposedInterface.Hooker { chain -> + if (!isDebugRecord(chain.args.firstOrNull())) return@Hooker chain.proceed() + if ((chain.executable as? Method)?.returnType == Boolean::class.javaPrimitiveType) false else null + } + GROUP_METHODS.forEach { hookMethods(groupHelper, it, blockGrouping) } + hookMethods(recordClass, "setOverrideGroupKey") { chain -> + if (isDebugRecord(chain.thisObject)) null else chain.proceed() + } + val dropSummary = XposedInterface.Hooker { chain -> + val info = notificationInfo(chain.args.toTypedArray()) + if (info?.packageName == APP_PACKAGE && !info.isDebug && info.isGroupSummary) null + else chain.proceed() + } + hookMethods(notificationManager, "enqueueNotificationInternal", dropSummary) + hookMethods(notificationManager, "enqueueNotificationInternalLocked", dropSummary) + Log.i(TAG, "Installed debug-notification auto-group guard") + } + + private fun hookMethods(type: Class<*>, name: String, hooker: XposedInterface.Hooker) { + type.declaredMethods.filter { it.name == name }.forEach { method -> + method.isAccessible = true + framework.hook(method).intercept(hooker) + } + } + + private fun isDebugRecord(record: Any?): Boolean { + val sbn = invoke(record, "getSbn") as? StatusBarNotification + val packageName = sbn?.packageName ?: invoke(record, "getPackageName") as? String + val notification = sbn?.notification ?: invoke(record, "getNotification") as? Notification + return packageName == APP_PACKAGE && notification?.extras?.getBoolean(DEBUG_EXTRA, false) == true + } + + private fun notificationInfo(args: Array): NotificationInfo? { + var packageName: String? = null + var notification: Notification? = null + args.forEach { arg -> + when (arg) { + is StatusBarNotification -> { + packageName = arg.packageName + notification = arg.notification + } + is Notification -> notification = arg + is String -> if (packageName == null && arg == APP_PACKAGE) packageName = arg + else -> if (arg?.javaClass?.name?.contains("NotificationRecord") == true) { + val sbn = invoke(arg, "getSbn") as? StatusBarNotification + packageName = sbn?.packageName ?: invoke(arg, "getPackageName") as? String ?: packageName + notification = sbn?.notification ?: invoke(arg, "getNotification") as? Notification ?: notification + } + } + } + val value = notification ?: return null + return NotificationInfo( + packageName ?: return null, + value.extras?.getBoolean(DEBUG_EXTRA, false) == true, + value.flags and Notification.FLAG_GROUP_SUMMARY != 0, + ) + } + + private fun invoke(instance: Any?, name: String): Any? { + var type = instance?.javaClass + while (type != null) { + val method = runCatching { type.getDeclaredMethod(name) }.getOrNull() + if (method != null) return runCatching { + method.isAccessible = true + method.invoke(instance) + }.getOrNull() + type = type.superclass + } + return null + } + + private fun findClass(name: String, classLoader: ClassLoader) = + runCatching { Class.forName(name, false, classLoader) }.getOrNull() + + private fun unavailable() { + Log.w(TAG, "Debug-notification auto-group boundary is unavailable") + } + + private data class NotificationInfo( + val packageName: String, + val isDebug: Boolean, + val isGroupSummary: Boolean, + ) + + private companion object { + const val TAG = "NotificationsMaster" + const val APP_PACKAGE = "se.ajpanton.notificationsmaster" + const val DEBUG_EXTRA = "se.ajpanton.notificationsmaster.DEBUG_NOTIFICATION" + const val GROUP_HELPER = "com.android.server.notification.GroupHelper" + const val NOTIFICATION_RECORD = "com.android.server.notification.NotificationRecord" + const val NOTIFICATION_MANAGER = "com.android.server.notification.NotificationManagerService" + val GROUP_METHODS = listOf( + "onNotificationPosted", + "onNotificationRemoved", + "maybeGroup", + "maybeUngroup", + "autoGroup", + "autogroup", + "maybeAutoGroup", + "shouldAutoGroup", + "isEligibleForAutoGroup", + ) + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt index 1aad498..ed7190d 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -43,6 +43,9 @@ class NotificationsMasterModule : XposedModule() { override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) { SystemAlertPolicyCache.installWhenReady() Handler(Looper.getMainLooper()).post { + installBridge("debug notification auto-group") { + DebugNotificationAutoGroupBridge(this).install(param.classLoader) + } installBridge("notification attention") { NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader) }