From cab90de5afbb4eeb39fd2e198c09bdbf042b2d1a Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sat, 29 Aug 2026 13:40:54 +0000 Subject: [PATCH] Filter native lockscreen and AOD surfaces --- .../visibility/VisibilityModuleFixtureTest.kt | 2 +- ...kedIconBackend.kt => Aosp34IconBackend.kt} | 52 ++++--- ...conBackend.kt => AospModernIconBackend.kt} | 81 ++++++++--- .../module/NotificationsMasterModule.kt | 30 ++-- .../module/OneUiLockscreenBackend.kt | 101 ++++++++++++++ .../module/OneUiUnlockedIconBackend.kt | 103 ++++++++++++++ .../module/SamsungAodBackend.kt | 129 ++++++++++++++++++ .../module/UnlockedIconBackend.kt | 6 - .../module/VisibilitySurfaceBackend.kt | 6 + 9 files changed, 454 insertions(+), 56 deletions(-) rename app/src/main/java/se/ajpanton/notificationsmaster/module/{Aosp34UnlockedIconBackend.kt => Aosp34IconBackend.kt} (60%) rename app/src/main/java/se/ajpanton/notificationsmaster/module/{AospModernUnlockedIconBackend.kt => AospModernIconBackend.kt} (78%) create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenBackend.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiUnlockedIconBackend.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt delete mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilitySurfaceBackend.kt diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt index 808e3c1..0a7ba2c 100644 --- a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt @@ -16,7 +16,7 @@ class VisibilityModuleFixtureTest { requireFixtureMode() store.save(VisibilityPolicy(apps = listOf(AppVisibilityPolicy( packageName = HELPER_PACKAGE, - blockedSurfaces = setOf(NotificationSurface.UNLOCKED_STATUSBAR), + blockedSurfaces = NotificationSurface.entries.toSet(), exceptions = listOf(VisibilityExceptionRule("Edited message", emptySet())), )))) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34UnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34IconBackend.kt similarity index 60% rename from app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34UnlockedIconBackend.kt rename to app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34IconBackend.kt index f14a863..1bb17d0 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34UnlockedIconBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp34IconBackend.kt @@ -10,16 +10,18 @@ import se.ajpanton.notificationsmaster.visibility.NotificationSurface import java.lang.reflect.Method import java.util.Collections import java.util.WeakHashMap +import java.util.concurrent.ConcurrentHashMap -/** API 34's class-id 1 mapper is used exclusively by the unlocked status-bar container. */ -internal class Aosp34UnlockedIconBackend(private val framework: XposedInterface) : UnlockedIconBackend { +/** API 34 uses distinct mapper IDs for its unlocked and AOD icon containers. */ +internal class Aosp34IconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend { private val controllers = Collections.newSetFromMap(WeakHashMap()) private lateinit var updateStatusBarIcons: Method - @Volatile private var blockedObserved = false + private var updateAodNotificationIcons: Method? = null + private val blockedSurfaces = ConcurrentHashMap.newKeySet() - override fun install(classLoader: ClassLoader) { - if (Build.VERSION.SDK_INT != 34) return - runCatching { + override fun install(classLoader: ClassLoader): Boolean { + if (Build.VERSION.SDK_INT != 34) return false + 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) @@ -37,24 +39,35 @@ internal class Aosp34UnlockedIconBackend(private val framework: XposedInterface) updateStatusBarIcons = controller.declaredMethods.singleOrNull { it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE } ?: error("Unlocked icon refresh method not found") + updateAodNotificationIcons = controller.declaredMethods.singleOrNull { + it.name == "updateAodNotificationIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE + } apply.isAccessible = true updateStatusBarIcons.isAccessible = true - framework.hook(updateStatusBarIcons).intercept { chain -> - synchronized(controllers) { controllers += chain.thisObject } - chain.proceed() + updateAodNotificationIcons?.isAccessible = true + listOfNotNull(updateStatusBarIcons, updateAodNotificationIcons).forEach { refresh -> + framework.hook(refresh).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 surface = when (classId.getInt(chain.thisObject)) { + AOD_CLASS_ID -> if (updateAodNotificationIcons != null) { + NotificationSurface.AOD + } else return@intercept chain.proceed() + STATUS_BAR_CLASS_ID -> NotificationSurface.UNLOCKED_STATUSBAR + else -> 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, + surface, )) return@intercept chain.proceed() - if (!blockedObserved) { - blockedObserved = true - Log.i(TAG, "Filtered an API 34 unlocked notification icon") + if (blockedSurfaces.add(surface)) { + Log.i(TAG, "Filtered an API 34 ${surface.name} notification icon") } null } @@ -62,22 +75,25 @@ internal class Aosp34UnlockedIconBackend(private val framework: XposedInterface) 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) - } + }.isSuccess } override fun onPolicyChanged() { - blockedObserved = false + blockedSurfaces.clear() 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) } + runCatching { + updateStatusBarIcons.invoke(controller) + updateAodNotificationIcons?.invoke(controller) + }.onFailure { Log.w(TAG, "Could not refresh API 34 notification icons", it) } } } } private companion object { const val TAG = "NotificationsMaster" + const val AOD_CLASS_ID = 0 const val STATUS_BAR_CLASS_ID = 1 const val CONTROLLER = "com.android.systemui.statusbar.phone.NotificationIconAreaController" const val ICON_MAPPER = "$CONTROLLER\$\$ExternalSyntheticLambda2" diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernUnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernIconBackend.kt similarity index 78% rename from app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernUnlockedIconBackend.kt rename to app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernIconBackend.kt index 89d036e..94c4911 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernUnlockedIconBackend.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AospModernIconBackend.kt @@ -12,17 +12,18 @@ import java.lang.reflect.Proxy import java.util.LinkedHashSet import java.util.concurrent.ConcurrentHashMap -/** Filters only the modern status-bar icon flow; the notification/shade model is untouched. */ -internal class AospModernUnlockedIconBackend(private val framework: XposedInterface) : UnlockedIconBackend { +/** Filters the modern unlocked and AOD icon flows; the notification/shade model is untouched. */ +internal class AospModernIconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend { private val metadata = ConcurrentHashMap() + private val activeKeys = ConcurrentHashMap>() @Volatile private var policyChanges: Any? = null private var policyRevision = 0L - @Volatile private var lastFilterSignature = -1L + private val lastFilterSignatures = ConcurrentHashMap() @Volatile private var captureFailureLogged = false - override fun install(classLoader: ClassLoader) { - if (Build.VERSION.SDK_INT !in 35..36) return - runCatching { + override fun install(classLoader: ClassLoader): Boolean { + if (Build.VERSION.SDK_INT !in 35..36) return false + return runCatching { val entryClass = Class.forName(NOTIFICATION_ENTRY, false, classLoader) val modelClass = Class.forName(ACTIVE_NOTIFICATION_MODEL, false, classLoader) val modelAccesses = buildList { @@ -39,11 +40,12 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf installMetadataCapture(classLoader, entryClass, modelClass) installFlowRefresh(classLoader) installStatusBarFilter(classLoader, modelAccesses) + installAodFilter(classLoader, modelAccesses) }.onSuccess { Log.i(TAG, "Installed AOSP API ${Build.VERSION.SDK_INT} unlocked notification-icon backend") }.onFailure { Log.w(TAG, "AOSP API ${Build.VERSION.SDK_INT} unlocked icon shape is unsupported; leaving icons unchanged", it) - } + }.isSuccess } @Synchronized override fun onPolicyChanged() { @@ -142,6 +144,9 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf else -> error("Unexpected Function3 method ${method.name}") } } + fun refreshed(upstream: Any?) = combineMethod?.invoke(null, upstream, signal, keepUpstreamValue) + ?: transformMethod?.let { (method, transform) -> method.invoke(null, upstream, signal, transform) } + ?: combineConstructor!!.newInstance(upstream, signal, keepUpstreamValue) policyChanges = signal val constructor = interactor.declaredConstructors.singleOrNull { it.parameterTypes.map { type -> type.name } == listOf( @@ -164,17 +169,24 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf 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) + field.set(chain.thisObject, refreshed(field.get(chain.thisObject))) }.onFailure { Log.w(TAG, "Could not attach visibility refresh flow; leaving icons unchanged", it) } result } } + runCatching { + val aod = Class.forName(AOD_VIEW_MODEL, false, classLoader) + val icons = aod.requiredField("icons", null).also { require(flowClass.isAssignableFrom(it.type)) } + require(aod.declaredConstructors.isNotEmpty()) { "AOD view model has no constructors" } + aod.declaredConstructors.forEach { constructor -> + constructor.isAccessible = true + framework.hook(constructor).intercept { chain -> + val result = chain.proceed() + icons.set(chain.thisObject, refreshed(icons.get(chain.thisObject))) + result + } + } + }.onFailure { Log.i(TAG, "AOD icon refresh flow is unavailable") } } private fun installStatusBarFilter( @@ -189,19 +201,42 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf emit.isAccessible = true framework.hook(emit).intercept { chain -> val original = chain.args[0] - val filtered = filter(original, modelAccesses) + val filtered = filter(original, modelAccesses, NotificationSurface.UNLOCKED_STATUSBAR) if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1])) } } - private fun filter(value: Any?, modelAccesses: List): Any? { + private fun installAodFilter(classLoader: ClassLoader, modelAccesses: List) { + runCatching { + val collector = Class.forName(AOD_MAP_COLLECTOR, false, classLoader) + val emit = collector.declaredMethods.singleOrNull(::isEmitMethod) + ?: error("AOD icon collector method not found") + emit.isAccessible = true + framework.hook(emit).intercept { chain -> + val original = chain.args[0] + val filtered = filter(original, modelAccesses, NotificationSurface.AOD) + if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1])) + } + }.onSuccess { + Log.i(TAG, "Installed AOSP AOD notification-icon backend") + }.onFailure { + Log.i(TAG, "AOSP AOD notification-icon boundary is unavailable") + } + } + + private fun filter( + value: Any?, + modelAccesses: List, + surface: NotificationSurface, + ): Any? { val entries = value as? Set<*> ?: return value 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 -> + val surfaceKeys = entries.mapNotNull { model -> model?.let { access(it).key.get(it) as? String } }.toSet() - metadata.keys.retainAll(activeKeys) + activeKeys[surface] = surfaceKeys + metadata.keys.retainAll(activeKeys.values.flatten().toSet()) var changed = false var missingMetadata = 0 val filtered = entries.filterTo(LinkedHashSet(entries.size)) { model -> @@ -217,17 +252,16 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf if (hasExceptions && captured == null) missingMetadata += 1 val notification = captured ?: VisibilityNotification(packageName, key) val blocked = (!hasExceptions || captured != null) && - ProcessVisibilityPolicyCache.isBlocked(notification, NotificationSurface.UNLOCKED_STATUSBAR) + ProcessVisibilityPolicyCache.isBlocked(notification, surface) changed = changed || blocked !blocked } val hidden = entries.size - filtered.size val signature = ((entries.size * 31L + hidden) * 31L + missingMetadata) - if (signature != lastFilterSignature) { - lastFilterSignature = signature + if (lastFilterSignatures.put(surface, signature) != signature) { Log.i( TAG, - "Unlocked notification icons: input=${entries.size}, hidden=$hidden, " + + "${surface.name} notification icons: input=${entries.size}, hidden=$hidden, " + "missingMetadata=$missingMetadata", ) } @@ -262,6 +296,9 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf "com.android.systemui.statusbar.notification.icon.domain.interactor.StatusBarNotificationIconsInteractor" const val STATUS_BAR_VIEW_MODEL = "com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerStatusBarViewModel" + const val AOD_VIEW_MODEL = + "com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerAlwaysOnDisplayViewModel" + const val AOD_MAP_COLLECTOR = "$AOD_VIEW_MODEL\$special\$\$inlined\$map\$1\$2" const val STATUS_BAR_MAP_COLLECTOR_PREFIX = "com.android.systemui.statusbar.notification.icon.ui.viewmodel." + "NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$" 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 54c2b84..2bb8b46 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -11,19 +11,31 @@ import io.github.libxposed.api.XposedModuleInterface * in a compatible framework and grants it a system-server scope. */ class NotificationsMasterModule : XposedModule() { - private var systemUiBackend: UnlockedIconBackend? = null + private var systemUiBackends: List? = null + private var aodBackends: List? = 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 = if (android.os.Build.VERSION.SDK_INT == 34) { - Aosp34UnlockedIconBackend(this) - } else AospModernUnlockedIconBackend(this) - backend.install(param.classLoader) - systemUiBackend = backend - ProcessVisibilityPolicyCache.installWhenReady(backend::onPolicyChanged) - } else ProcessVisibilityPolicyCache.installWhenReady() + if (systemUiBackends != null) return + val unlocked = listOf( + OneUiUnlockedIconBackend(this), + Aosp34IconBackend(this), + AospModernIconBackend(this), + ).firstOrNull { it.install(param.classLoader) } + systemUiBackends = listOfNotNull(unlocked) + listOf( + OneUiLockscreenBackend(this), + ).filter { it.install(param.classLoader) } + ProcessVisibilityPolicyCache.installWhenReady { + systemUiBackends.orEmpty().forEach(VisibilitySurfaceBackend::onPolicyChanged) + } + } else { + if (aodBackends != null) return + aodBackends = listOf(SamsungAodBackend(this)).filter { it.install(param.classLoader) } + ProcessVisibilityPolicyCache.installWhenReady { + aodBackends.orEmpty().forEach(VisibilitySurfaceBackend::onPolicyChanged) + } + } } override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) { diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenBackend.kt new file mode 100644 index 0000000..17ee98b --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiLockscreenBackend.kt @@ -0,0 +1,101 @@ +package se.ajpanton.notificationsmaster.module + +import android.os.Handler +import android.os.Looper +import android.os.Message +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.ArrayList +import java.util.Collections +import java.util.WeakHashMap + +/** Filters OneUI's collapsed lockscreen projection without changing notification rows. */ +internal class OneUiLockscreenBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend { + private val coordinators = Collections.newSetFromMap(WeakHashMap()) + private lateinit var refresh: Method + @Volatile private var blockedObserved = false + + override fun install(classLoader: ClassLoader): Boolean = runCatching { + val coordinator = Class.forName(COORDINATOR, false, classLoader) + val managerHandler = Class.forName(MANAGER_HANDLER, false, classLoader) + val info = Class.forName(NOTIFICATION_INFO, false, classLoader) + val sbn = info.getDeclaredField("mSbn").also { + require(it.type == StatusBarNotification::class.java) + it.isAccessible = true + } + val attach = coordinator.declaredMethods.singleOrNull { + it.name == "attach" && it.parameterTypes.size == 1 && it.returnType == Void.TYPE + } ?: error("OneUI lockscreen coordinator attach method not found") + refresh = coordinator.declaredMethods.singleOrNull { + it.name == "onLockScreenNotiStateChanged" && + it.parameterTypes.isEmpty() && it.returnType == Void.TYPE + } ?: error("OneUI lockscreen refresh method not found") + val handle = managerHandler.declaredMethods.singleOrNull { + it.name == "handleMessage" && + it.parameterTypes.contentEquals(arrayOf(Message::class.java)) && it.returnType == Void.TYPE + } ?: error("OneUI lockscreen update handler not found") + attach.isAccessible = true + refresh.isAccessible = true + handle.isAccessible = true + framework.hook(attach).intercept { chain -> + synchronized(coordinators) { coordinators += chain.thisObject } + chain.proceed() + } + framework.hook(handle).intercept { chain -> + val message = chain.args[0] as? Message ?: return@intercept chain.proceed() + if (message.what != NOTIFICATION_INFO_UPDATED) return@intercept chain.proceed() + val original = message.obj as? ArrayList<*> ?: return@intercept chain.proceed() + if (original.any { it != null && !info.isInstance(it) }) return@intercept chain.proceed() + val filtered = original.filterTo(ArrayList(original.size)) { item -> + val notification = item?.let { + runCatching { + VisibilityNotificationExtractor.from(sbn.get(it) as StatusBarNotification) + }.getOrNull() + } + notification == null || !ProcessVisibilityPolicyCache.isBlocked( + notification, + NotificationSurface.LOCKSCREEN_COLLAPSED, + ) + } + if (filtered.size == original.size) return@intercept chain.proceed() + if (!blockedObserved) { + blockedObserved = true + Log.i(TAG, "Filtered a OneUI collapsed-lockscreen notification") + } + message.obj = filtered + try { + chain.proceed() + } finally { + message.obj = original + } + } + }.onSuccess { + Log.i(TAG, "Installed OneUI collapsed-lockscreen notification backend") + }.onFailure { + Log.d(TAG, "OneUI collapsed-lockscreen boundary is unavailable") + }.isSuccess + + override fun onPolicyChanged() { + blockedObserved = false + Handler(Looper.getMainLooper()).post { + val current = synchronized(coordinators) { coordinators.toList() } + current.forEach { coordinator -> + runCatching { refresh.invoke(coordinator) } + .onFailure { Log.w(TAG, "Could not refresh OneUI collapsed-lockscreen notifications", it) } + } + } + } + + private companion object { + const val TAG = "NotificationsMaster" + const val NOTIFICATION_INFO_UPDATED = 101 + const val COORDINATOR = + "com.android.systemui.statusbar.notification.collection.coordinator.LockScreenNotiIconCoordinator" + const val MANAGER_HANDLER = + "com.android.systemui.statusbar.LockscreenNotificationManager\$LockscreenNotificationMgrHandler" + const val NOTIFICATION_INFO = "com.android.systemui.statusbar.LockscreenNotificationInfo" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiUnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiUnlockedIconBackend.kt new file mode 100644 index 0000000..fc9a7f5 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/OneUiUnlockedIconBackend.kt @@ -0,0 +1,103 @@ +package se.ajpanton.notificationsmaster.module + +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 +import java.util.function.Function + +/** Filters only OneUI's native unlocked status-bar container. */ +internal class OneUiUnlockedIconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend { + private val controllers = Collections.newSetFromMap(WeakHashMap()) + private lateinit var updateStatusBarIcons: Method + @Volatile private var blockedObserved = false + + override fun install(classLoader: ClassLoader): Boolean = runCatching { + val controller = Class.forName(CONTROLLER, false, classLoader) + val container = Class.forName(ICON_CONTAINER, false, classLoader) + val entry = Class.forName(NOTIFICATION_ENTRY, false, classLoader) + val notificationIcons = controller.getDeclaredField("mNotificationIcons").also { + require(it.type == container) + it.isAccessible = true + } + val sbn = entry.getDeclaredField("mSbn").also { + require(it.type == StatusBarNotification::class.java) + it.isAccessible = true + } + val update = controller.declaredMethods.singleOrNull { + it.name == "updateIconsForLayout" && + it.returnType == Void.TYPE && + it.parameterTypes.contentEquals( + arrayOf( + Function::class.java, + container, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + ), + ) + } ?: error("OneUI icon update method not found") + updateStatusBarIcons = controller.declaredMethods.singleOrNull { + it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE + } ?: error("OneUI status-bar icon refresh method not found") + update.isAccessible = true + updateStatusBarIcons.isAccessible = true + framework.hook(updateStatusBarIcons).intercept { chain -> + synchronized(controllers) { controllers += chain.thisObject } + chain.proceed() + } + framework.hook(update).intercept { chain -> + if (chain.args[1] !== notificationIcons.get(chain.thisObject)) return@intercept chain.proceed() + @Suppress("UNCHECKED_CAST") + val original = chain.args[0] as? Function ?: return@intercept chain.proceed() + val wrapped = Function { item -> + val notification = runCatching { + VisibilityNotificationExtractor.from(sbn.get(item) as StatusBarNotification) + }.getOrNull() + if (notification == null || !ProcessVisibilityPolicyCache.isBlocked( + notification, + NotificationSurface.UNLOCKED_STATUSBAR, + )) { + original.apply(item) + } else { + if (!blockedObserved) { + blockedObserved = true + Log.i(TAG, "Filtered a OneUI unlocked notification icon") + } + null + } + } + val args = chain.args.toTypedArray() + args[0] = wrapped + chain.proceed(args) + } + }.onSuccess { + Log.i(TAG, "Installed OneUI unlocked notification-icon backend") + }.onFailure { + Log.d(TAG, "OneUI unlocked icon boundary is unavailable") + }.isSuccess + + 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 OneUI unlocked icons", it) } + } + } + } + + private companion object { + const val TAG = "NotificationsMaster" + const val CONTROLLER = "com.android.systemui.statusbar.phone.LegacyNotificationIconAreaControllerImpl" + const val ICON_CONTAINER = "com.android.systemui.statusbar.phone.NotificationIconContainer" + const val NOTIFICATION_ENTRY = "com.android.systemui.statusbar.notification.collection.NotificationEntry" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt new file mode 100644 index 0000000..0742356 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SamsungAodBackend.kt @@ -0,0 +1,129 @@ +package se.ajpanton.notificationsmaster.module + +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 + +/** 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 refreshing = ThreadLocal() + private lateinit var updateActive: Method + private lateinit var updateVisible: Method + @Volatile private var blockedObserved = false + + override fun install(classLoader: ClassLoader): Boolean = runCatching { + val manager = Class.forName(MANAGER, false, classLoader) + updateActive = manager.exactMethod("updateActiveNotifications", List::class.java) + updateVisible = manager.exactMethod( + "updateVisibleNotifications", + List::class.java, + List::class.java, + Int::class.javaPrimitiveType!!, + ) + framework.hook(updateActive).intercept { chain -> + val raw = notificationList(chain.args[0]) ?: return@intercept chain.proceed() + if (refreshing.get() != true) state(chain.thisObject).active = raw + proceedFiltered(chain, 0, raw) + } + framework.hook(updateVisible).intercept { chain -> + val first = notificationList(chain.args[0]) ?: return@intercept chain.proceed() + val second = notificationList(chain.args[1]) ?: return@intercept chain.proceed() + if (refreshing.get() != true) { + state(chain.thisObject).visible = VisibleState(first, second, chain.args[2] as Int) + } + val filteredFirst = filter(first) + val filteredSecond = filter(second) + if (filteredFirst === first && filteredSecond === second) chain.proceed() else { + chain.proceed(arrayOf(filteredFirst, filteredSecond, chain.args[2])) + } + } + }.onSuccess { + Log.i(TAG, "Installed Samsung AOD notification backend") + }.onFailure { + Log.d(TAG, "Samsung AOD notification boundary is unavailable") + }.isSuccess + + override fun onPolicyChanged() { + blockedObserved = false + Handler(Looper.getMainLooper()).post { + val current = synchronized(managers) { managers.entries.map { it.key to it.value.copy() } } + refreshing.set(true) + try { + current.forEach { (manager, state) -> + state.active?.let { updateActive.invoke(manager, filter(it)) } + state.visible?.let { + updateVisible.invoke(manager, filter(it.first), filter(it.second), it.type) + } + } + } catch (error: ReflectiveOperationException) { + Log.w(TAG, "Could not refresh Samsung AOD notifications", error) + } finally { + refreshing.remove() + } + } + } + + private fun proceedFiltered( + chain: XposedInterface.Chain, + argument: Int, + raw: List, + ): Any? { + val filtered = filter(raw) + if (filtered === raw) return chain.proceed() + val args = chain.args.toTypedArray() + args[argument] = filtered + return chain.proceed(args) + } + + private fun filter(raw: List): List { + val filtered = raw.filterNot { sbn -> + runCatching { + ProcessVisibilityPolicyCache.isBlocked( + VisibilityNotificationExtractor.from(sbn), + NotificationSurface.AOD, + ) + }.getOrDefault(false) + } + if (filtered.size == raw.size) return raw + if (!blockedObserved) { + blockedObserved = true + Log.i(TAG, "Filtered a Samsung AOD notification") + } + return filtered + } + + private fun notificationList(value: Any?): List? { + val list = value as? List<*> ?: return null + if (list.any { it != null && it !is StatusBarNotification }) return null + @Suppress("UNCHECKED_CAST") + return ArrayList(list as List) + } + + private fun state(manager: Any) = synchronized(managers) { managers.getOrPut(manager, ::State) } + + private fun Class<*>.exactMethod(name: String, vararg parameters: Class<*>) = + getDeclaredMethod(name, *parameters).also { it.isAccessible = true } + + private data class State( + var active: List? = null, + var visible: VisibleState? = null, + ) + + private data class VisibleState( + val first: List, + val second: List, + val type: Int, + ) + + private companion object { + const val TAG = "NotificationsMaster" + const val MANAGER = "com.samsung.android.uniform.plugins.notification.AODNotificationManager" + } +} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt deleted file mode 100644 index e364ed1..0000000 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/UnlockedIconBackend.kt +++ /dev/null @@ -1,6 +0,0 @@ -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/VisibilitySurfaceBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilitySurfaceBackend.kt new file mode 100644 index 0000000..abbbeb8 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilitySurfaceBackend.kt @@ -0,0 +1,6 @@ +package se.ajpanton.notificationsmaster.module + +internal interface VisibilitySurfaceBackend { + fun install(classLoader: ClassLoader): Boolean + fun onPolicyChanged() +}