From 4fcbffd4a5b1fac1539e8744cc9275be5caa667c Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sat, 29 Aug 2026 13:08:40 +0000 Subject: [PATCH] Support unlocked icon filtering across Android variants --- .../module/Aosp34UnlockedIconBackend.kt | 86 +++++++++ ...nd.kt => AospModernUnlockedIconBackend.kt} | 176 +++++++++++------- .../module/NotificationsMasterModule.kt | 6 +- .../module/UnlockedIconBackend.kt | 6 + .../module/VisibilityNotificationExtractor.kt | 32 ++++ 5 files changed, 241 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34UnlockedIconBackend.kt rename app/src/main/java/se/ajpanton/notificationsmaster/module/{Aosp36UnlockedIconBackend.kt => AospModernUnlockedIconBackend.kt} (53%) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityNotificationExtractor.kt diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34UnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34UnlockedIconBackend.kt new file mode 100644 index 0000000..f14a863 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34UnlockedIconBackend.kt @@ -0,0 +1,86 @@ +package se.ajpanton.notificationsmaster.module + +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.service.notification.StatusBarNotification +import android.util.Log +import io.github.libxposed.api.XposedInterface +import se.ajpanton.notificationsmaster.visibility.NotificationSurface +import java.lang.reflect.Method +import java.util.Collections +import java.util.WeakHashMap + +/** API 34's class-id 1 mapper is used exclusively by the unlocked status-bar container. */ +internal class Aosp34UnlockedIconBackend(private val framework: XposedInterface) : UnlockedIconBackend { + private val controllers = Collections.newSetFromMap(WeakHashMap()) + private lateinit var updateStatusBarIcons: Method + @Volatile private var blockedObserved = false + + override fun install(classLoader: ClassLoader) { + if (Build.VERSION.SDK_INT != 34) return + runCatching { + val controller = Class.forName(CONTROLLER, false, classLoader) + val mapper = Class.forName(ICON_MAPPER, false, classLoader) + val entry = Class.forName(NOTIFICATION_ENTRY, false, classLoader) + val classId = mapper.getDeclaredField("\$r8\$classId").also { + require(it.type == Int::class.javaPrimitiveType) + it.isAccessible = true + } + val sbn = entry.getDeclaredField("mSbn").also { + require(it.type == StatusBarNotification::class.java) + it.isAccessible = true + } + val apply = mapper.declaredMethods.singleOrNull { + it.name == "apply" && it.parameterTypes.contentEquals(arrayOf(Any::class.java)) + } ?: error("Unlocked icon mapper method not found") + updateStatusBarIcons = controller.declaredMethods.singleOrNull { + it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE + } ?: error("Unlocked icon refresh method not found") + apply.isAccessible = true + updateStatusBarIcons.isAccessible = true + framework.hook(updateStatusBarIcons).intercept { chain -> + synchronized(controllers) { controllers += chain.thisObject } + chain.proceed() + } + framework.hook(apply).intercept { chain -> + if (classId.getInt(chain.thisObject) != STATUS_BAR_CLASS_ID) return@intercept chain.proceed() + val notification = runCatching { + VisibilityNotificationExtractor.from(sbn.get(chain.args[0]) as StatusBarNotification) + }.getOrNull() ?: return@intercept chain.proceed() + if (!ProcessVisibilityPolicyCache.isBlocked( + notification, + NotificationSurface.UNLOCKED_STATUSBAR, + )) return@intercept chain.proceed() + if (!blockedObserved) { + blockedObserved = true + Log.i(TAG, "Filtered an API 34 unlocked notification icon") + } + null + } + }.onSuccess { + Log.i(TAG, "Installed AOSP API 34 unlocked notification-icon backend") + }.onFailure { + Log.w(TAG, "AOSP API 34 unlocked icon shape is unsupported; leaving icons unchanged", it) + } + } + + override fun onPolicyChanged() { + blockedObserved = false + Handler(Looper.getMainLooper()).post { + val current = synchronized(controllers) { controllers.toList() } + current.forEach { controller -> + runCatching { updateStatusBarIcons.invoke(controller) } + .onFailure { Log.w(TAG, "Could not refresh API 34 unlocked icons", it) } + } + } + } + + private companion object { + const val TAG = "NotificationsMaster" + const val STATUS_BAR_CLASS_ID = 1 + const val CONTROLLER = "com.android.systemui.statusbar.phone.NotificationIconAreaController" + const val ICON_MAPPER = "$CONTROLLER\$\$ExternalSyntheticLambda2" + const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp36UnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernUnlockedIconBackend.kt similarity index 53% rename from app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp36UnlockedIconBackend.kt rename to app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernUnlockedIconBackend.kt index 0b0d07a..89d036e 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp36UnlockedIconBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernUnlockedIconBackend.kt @@ -1,8 +1,6 @@ package se.ajpanton.notificationsmaster.module -import android.app.Notification import android.os.Build -import android.os.Bundle import android.service.notification.StatusBarNotification import android.util.Log import io.github.libxposed.api.XposedInterface @@ -14,34 +12,41 @@ import java.lang.reflect.Proxy import java.util.LinkedHashSet import java.util.concurrent.ConcurrentHashMap -/** Filters only API 36's status-bar icon flow; the notification/shade model is untouched. */ -internal class Aosp36UnlockedIconBackend(private val framework: XposedInterface) { +/** Filters only the modern status-bar icon flow; the notification/shade model is untouched. */ +internal class AospModernUnlockedIconBackend(private val framework: XposedInterface) : UnlockedIconBackend { private val metadata = ConcurrentHashMap() @Volatile private var policyChanges: Any? = null private var policyRevision = 0L @Volatile private var lastFilterSignature = -1L @Volatile private var captureFailureLogged = false - fun install(classLoader: ClassLoader) { - if (Build.VERSION.SDK_INT != 36) return + override fun install(classLoader: ClassLoader) { + if (Build.VERSION.SDK_INT !in 35..36) return runCatching { val entryClass = Class.forName(NOTIFICATION_ENTRY, false, classLoader) val modelClass = Class.forName(ACTIVE_NOTIFICATION_MODEL, false, classLoader) - val modelAccess = ModelAccess( - modelClass.requiredField("key", String::class.java), - modelClass.requiredField("packageName", String::class.java), - ) + val modelAccesses = buildList { + add(ModelAccess( + modelClass, + modelClass.requiredField("key", String::class.java), + modelClass.requiredField("packageName", String::class.java), + )) + runCatching { Class.forName(ACTIVE_NOTIFICATION_ICON_MODEL, false, classLoader) } + .getOrNull()?.let { iconModel -> + add(ModelAccess(iconModel, iconModel.requiredField("notifKey", String::class.java), null)) + } + } installMetadataCapture(classLoader, entryClass, modelClass) installFlowRefresh(classLoader) - installStatusBarFilter(classLoader, modelClass, modelAccess) + installStatusBarFilter(classLoader, modelAccesses) }.onSuccess { - Log.i(TAG, "Installed AOSP 36 unlocked notification-icon backend") + Log.i(TAG, "Installed AOSP API ${Build.VERSION.SDK_INT} unlocked notification-icon backend") }.onFailure { - Log.w(TAG, "AOSP 36 unlocked icon shape is unsupported; leaving icons unchanged", it) + Log.w(TAG, "AOSP API ${Build.VERSION.SDK_INT} unlocked icon shape is unsupported; leaving icons unchanged", it) } } - @Synchronized fun onPolicyChanged() { + @Synchronized override fun onPolicyChanged() { val signal = policyChanges ?: return policyRevision += 1 runCatching { @@ -60,7 +65,7 @@ internal class Aosp36UnlockedIconBackend(private val framework: XposedInterface) framework.hook(method).intercept { chain -> val result = chain.proceed() runCatching { sbnField.get(chain.args[0]) as StatusBarNotification } - .map(::visibilityNotification) + .map(VisibilityNotificationExtractor::from) .onSuccess { metadata[it.key] = it } .onFailure { if (!captureFailureLogged) { @@ -81,15 +86,53 @@ internal class Aosp36UnlockedIconBackend(private val framework: XposedInterface) val signal = Class.forName("kotlinx.coroutines.flow.StateFlowKt", false, classLoader) .declaredMethods.single { it.name == "MutableStateFlow" && it.parameterTypes.size == 1 } .invoke(null, policyRevision) - val combine = Class.forName(TWO_FLOW_COMBINE, false, classLoader) - .declaredConstructors.single { - it.parameterTypes.map { type -> type.name } == listOf( + val combineMethod = Class.forName("kotlinx.coroutines.flow.FlowKt", false, classLoader) + .methods.singleOrNull { + it.name == "combine" && it.parameterTypes.map(Class<*>::getName) == listOf( flowClass.name, flowClass.name, functionClass.name, ) } - .also { it.isAccessible = true } + val transformMethod = if (combineMethod == null) { + val function4 = Class.forName("kotlin.jvm.functions.Function4", false, classLoader) + Class.forName("kotlinx.coroutines.flow.FlowKt", false, classLoader).methods.singleOrNull { + it.name == "combineTransform" && it.parameterTypes.map(Class<*>::getName) == listOf( + flowClass.name, + flowClass.name, + function4.name, + ) + }?.let { method -> + val collector = Class.forName("kotlinx.coroutines.flow.FlowCollector", false, classLoader) + val continuation = Class.forName("kotlin.coroutines.Continuation", false, classLoader) + val emit = collector.getMethod("emit", Any::class.java, continuation) + method to Proxy.newProxyInstance(classLoader, arrayOf(function4)) { proxy, called, args -> + when (called.name) { + "invoke" -> emit.invoke(args!![0], args[1], args[3]) + "toString" -> "NotificationsMasterVisibilityTransform" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.get(0) + else -> error("Unexpected Function4 method ${called.name}") + } + } + } + } else { + null + } + val combineConstructor = if (combineMethod == null && transformMethod == null) { + Class.forName(TWO_FLOW_COMBINE, false, classLoader).declaredConstructors.singleOrNull { + it.parameterTypes.map(Class<*>::getName) == listOf( + flowClass.name, + flowClass.name, + functionClass.name, + ) + }?.also { it.isAccessible = true } + } else { + null + } + require(combineMethod != null || transformMethod != null || combineConstructor != null) { + "Two-flow combine implementation not found" + } val keepUpstreamValue = Proxy.newProxyInstance(classLoader, arrayOf(functionClass)) { proxy, method, args -> when (method.name) { "invoke" -> args?.get(0) @@ -107,46 +150,69 @@ internal class Aosp36UnlockedIconBackend(private val framework: XposedInterface) "com.android.systemui.statusbar.data.repository.NotificationListenerSettingsRepository", ) } - ?: error("Status-bar notification interactor constructor not found") - constructor.isAccessible = true - framework.hook(constructor).intercept { chain -> - val result = chain.proceed() - runCatching { - val upstream = flowField.get(chain.thisObject) - flowField.set(chain.thisObject, combine.newInstance(upstream, signal, keepUpstreamValue)) - }.onFailure { Log.w(TAG, "Could not attach visibility refresh flow; leaving icons unchanged", it) } - result + val (field, constructors) = if (constructor != null) { + flowField to listOf(constructor) + } else { + val viewModel = Class.forName(STATUS_BAR_VIEW_MODEL, false, classLoader) + viewModel.requiredField("icons", null).also { + require(flowClass.isAssignableFrom(it.type)) + } to viewModel.declaredConstructors.toList() + } + require(constructors.isNotEmpty()) { "Status-bar flow owner has no constructors" } + constructors.forEach { target -> + target.isAccessible = true + framework.hook(target).intercept { chain -> + val result = chain.proceed() + runCatching { + val upstream = field.get(chain.thisObject) + val combined = combineMethod?.invoke(null, upstream, signal, keepUpstreamValue) + ?: transformMethod?.let { (method, transform) -> + method.invoke(null, upstream, signal, transform) + } + ?: combineConstructor!!.newInstance(upstream, signal, keepUpstreamValue) + field.set(chain.thisObject, combined) + }.onFailure { Log.w(TAG, "Could not attach visibility refresh flow; leaving icons unchanged", it) } + result + } } } private fun installStatusBarFilter( classLoader: ClassLoader, - modelClass: Class<*>, - access: ModelAccess, + modelAccesses: List, ) { - val collector = Class.forName(STATUS_BAR_MAP_COLLECTOR, false, classLoader) + val collector = statusBarMapCollectors().firstNotNullOfOrNull { name -> + runCatching { Class.forName(name, false, classLoader) }.getOrNull() + } ?: error("Status-bar icon collector class not found") val emit = collector.declaredMethods.singleOrNull(::isEmitMethod) ?: error("Status-bar icon collector method not found") emit.isAccessible = true framework.hook(emit).intercept { chain -> val original = chain.args[0] - val filtered = filter(original, modelClass, access) + val filtered = filter(original, modelAccesses) if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1])) } } - private fun filter(value: Any?, modelClass: Class<*>, access: ModelAccess): Any? { + private fun filter(value: Any?, modelAccesses: List): Any? { val entries = value as? Set<*> ?: return value - if (entries.any { it != null && !modelClass.isInstance(it) }) return value - val activeKeys = entries.mapNotNull { access.key.get(it) as? String }.toSet() + if (entries.any { model -> model != null && modelAccesses.none { it.type.isInstance(model) } }) return value + fun access(model: Any) = modelAccesses.first { it.type.isInstance(model) } + val activeKeys = entries.mapNotNull { model -> + model?.let { access(it).key.get(it) as? String } + }.toSet() metadata.keys.retainAll(activeKeys) var changed = false var missingMetadata = 0 val filtered = entries.filterTo(LinkedHashSet(entries.size)) { model -> if (model == null) return@filterTo true - val key = access.key.get(model) as? String ?: return@filterTo true - val packageName = access.packageName.get(model) as? String ?: return@filterTo true + val fields = access(model) + val key = fields.key.get(model) as? String ?: return@filterTo true val captured = metadata[key] + val packageName = fields.packageName?.get(model) as? String + ?: captured?.packageName + ?: key.split('|', limit = 3).getOrNull(1) + ?: return@filterTo true val hasExceptions = ProcessVisibilityPolicyCache.hasExceptions(packageName) if (hasExceptions && captured == null) missingMetadata += 1 val notification = captured ?: VisibilityNotification(packageName, key) @@ -168,7 +234,7 @@ internal class Aosp36UnlockedIconBackend(private val framework: XposedInterface) return if (changed) filtered else value } - private data class ModelAccess(val key: Field, val packageName: Field) + private data class ModelAccess(val type: Class<*>, val key: Field, val packageName: Field?) private fun Class<*>.requiredField(name: String, type: Class<*>?): Field = getDeclaredField(name).also { @@ -179,42 +245,26 @@ internal class Aosp36UnlockedIconBackend(private val framework: XposedInterface) private fun isEmitMethod(method: Method) = method.name == "emit" && method.parameterTypes.size == 2 && method.parameterTypes[1].name == "kotlin.coroutines.Continuation" - private fun visibilityNotification(sbn: StatusBarNotification): VisibilityNotification { - val notification = sbn.notification - val extras = notification.extras ?: Bundle.EMPTY - val messages = listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).flatMap { key -> - Notification.MessagingStyle.Message.getMessagesFromBundleArray( - extras.getParcelableArray(key, Bundle::class.java), - ).map { message -> - listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ") - } - } - return VisibilityNotification( - packageName = sbn.packageName, - key = sbn.key, - title = extras.text(Notification.EXTRA_TITLE), - text = extras.text(Notification.EXTRA_TEXT), - bigText = extras.text(Notification.EXTRA_BIG_TEXT), - subtext = extras.text(Notification.EXTRA_SUB_TEXT), - messages = messages, - channelId = notification.channelId, - ) - } - - private fun Bundle.text(key: String) = getCharSequence(key)?.toString() + private fun statusBarMapCollectors() = + (if (Build.VERSION.SDK_INT == 35) listOf("2\$2", "1\$2") else listOf("1\$2")) + .map { STATUS_BAR_MAP_COLLECTOR_PREFIX + it } private companion object { const val TAG = "NotificationsMaster" const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry" const val ACTIVE_NOTIFICATION_MODEL = "com.android.systemui.statusbar.notification.shared.ActiveNotificationModel" + const val ACTIVE_NOTIFICATION_ICON_MODEL = + "com.android.systemui.statusbar.notification.icon.domain.interactor.ActiveNotificationIconModel" const val ACTIVE_STORE_BUILDER = "com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsStoreBuilder" const val STATUS_BAR_INTERACTOR = "com.android.systemui.statusbar.notification.icon.domain.interactor.StatusBarNotificationIconsInteractor" - const val STATUS_BAR_MAP_COLLECTOR = + const val STATUS_BAR_VIEW_MODEL = + "com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerStatusBarViewModel" + const val STATUS_BAR_MAP_COLLECTOR_PREFIX = "com.android.systemui.statusbar.notification.icon.ui.viewmodel." + - "NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$1\$2" + "NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$" const val TWO_FLOW_COMBINE = "kotlinx.coroutines.flow.FlowKt__ZipKt\$combine\$\$inlined\$unsafeFlow\$1" } 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 9f57cd7..54c2b84 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -11,13 +11,15 @@ import io.github.libxposed.api.XposedModuleInterface * in a compatible framework and grants it a system-server scope. */ class NotificationsMasterModule : XposedModule() { - private var systemUiBackend: Aosp36UnlockedIconBackend? = null + private var systemUiBackend: UnlockedIconBackend? = null override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) { if (param.packageName !in VisibilityPolicySync.TARGET_PACKAGES) return if (param.packageName == SYSTEM_UI_PACKAGE) { if (systemUiBackend != null) return - val backend = Aosp36UnlockedIconBackend(this) + val backend = if (android.os.Build.VERSION.SDK_INT == 34) { + Aosp34UnlockedIconBackend(this) + } else AospModernUnlockedIconBackend(this) backend.install(param.classLoader) systemUiBackend = backend ProcessVisibilityPolicyCache.installWhenReady(backend::onPolicyChanged) diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt new file mode 100644 index 0000000..e364ed1 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt @@ -0,0 +1,6 @@ +package se.ajpanton.notificationsmaster.module + +internal interface UnlockedIconBackend { + fun install(classLoader: ClassLoader) + fun onPolicyChanged() +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityNotificationExtractor.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityNotificationExtractor.kt new file mode 100644 index 0000000..74b7eda --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityNotificationExtractor.kt @@ -0,0 +1,32 @@ +package se.ajpanton.notificationsmaster.module + +import android.app.Notification +import android.os.Bundle +import android.service.notification.StatusBarNotification +import se.ajpanton.notificationsmaster.visibility.VisibilityNotification + +internal object VisibilityNotificationExtractor { + fun from(sbn: StatusBarNotification): VisibilityNotification { + val notification = sbn.notification + val extras = notification.extras ?: Bundle.EMPTY + val messages = listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).flatMap { key -> + Notification.MessagingStyle.Message.getMessagesFromBundleArray( + extras.getParcelableArray(key, Bundle::class.java), + ).map { message -> + listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ") + } + } + return VisibilityNotification( + packageName = sbn.packageName, + key = sbn.key, + title = extras.text(Notification.EXTRA_TITLE), + text = extras.text(Notification.EXTRA_TEXT), + bigText = extras.text(Notification.EXTRA_BIG_TEXT), + subtext = extras.text(Notification.EXTRA_SUB_TEXT), + messages = messages, + channelId = notification.channelId, + ) + } + + private fun Bundle.text(key: String) = getCharSequence(key)?.toString() +}