From 38a099f5d392ac3f3eafeb5f9118ebbc0599533c Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sat, 29 Aug 2026 12:36:21 +0000 Subject: [PATCH] Filter AOSP 36 unlocked notification icons --- .../alerts/AlertListenerPolicyDeviceTest.kt | 8 + .../visibility/VisibilityModuleFixtureTest.kt | 34 +++ .../visibility/VisibilityPolicyStoreTest.kt | 3 +- .../module/Aosp36UnlockedIconBackend.kt | 221 ++++++++++++++++++ .../module/NotificationsMasterModule.kt | 14 +- .../module/ProcessVisibilityPolicyCache.kt | 2 + .../visibility/VisibilityPolicy.kt | 2 + 7 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp36UnlockedIconBackend.kt diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AlertListenerPolicyDeviceTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AlertListenerPolicyDeviceTest.kt index a2c08e2..4d65044 100644 --- a/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AlertListenerPolicyDeviceTest.kt +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AlertListenerPolicyDeviceTest.kt @@ -4,21 +4,29 @@ import android.content.ComponentName import android.content.pm.PackageManager import androidx.test.platform.app.InstrumentationRegistry import org.junit.After +import org.junit.Before import org.junit.Assert.assertEquals import org.junit.Test import se.ajpanton.notificationsmaster.capture.NotificationCaptureService import se.ajpanton.notificationsmaster.settings.LoggingRule import se.ajpanton.notificationsmaster.settings.LoggingRuleStore import se.ajpanton.notificationsmaster.settings.LoggingType +import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy +import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore class AlertListenerPolicyDeviceTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val component = ComponentName(context, NotificationCaptureService::class.java) + @Before fun clearVisibilityRules() { + VisibilityPolicyStore(context).save(VisibilityPolicy()) + } + @After fun restoreDefaults() { LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule()) } AlertConfigurationStore(context).save(AlertConfiguration()) + VisibilityPolicyStore(context).save(VisibilityPolicy()) } @Test diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt new file mode 100644 index 0000000..808e3c1 --- /dev/null +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityModuleFixtureTest.kt @@ -0,0 +1,34 @@ +package se.ajpanton.notificationsmaster.visibility + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Explicitly invoked module-test fixtures; normal connected tests skip them. */ +@RunWith(AndroidJUnit4::class) +class VisibilityModuleFixtureTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val store = VisibilityPolicyStore(instrumentation.targetContext) + + @Test fun blockHelperExceptEditedText() { + requireFixtureMode() + store.save(VisibilityPolicy(apps = listOf(AppVisibilityPolicy( + packageName = HELPER_PACKAGE, + blockedSurfaces = setOf(NotificationSurface.UNLOCKED_STATUSBAR), + exceptions = listOf(VisibilityExceptionRule("Edited message", emptySet())), + )))) + } + + @Test fun allowHelper() { + requireFixtureMode() + store.save(VisibilityPolicy()) + } + + private fun requireFixtureMode() { + assumeTrue(InstrumentationRegistry.getArguments().getString("visibilityFixture") == "true") + } + + private companion object { const val HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper" } +} diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt index d516dc4..45d2520 100644 --- a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt @@ -3,6 +3,7 @@ package se.ajpanton.notificationsmaster.visibility import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.After +import org.junit.Before import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -14,7 +15,7 @@ import java.io.File class VisibilityPolicyStoreTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext - @After fun cleanUp() { + @Before @After fun cleanUp() { File(context.filesDir, "visibility-policy.bin").delete() File(context.filesDir, "visibility-policy.bin.bak").delete() } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp36UnlockedIconBackend.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp36UnlockedIconBackend.kt new file mode 100644 index 0000000..0b0d07a --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/Aosp36UnlockedIconBackend.kt @@ -0,0 +1,221 @@ +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 +import se.ajpanton.notificationsmaster.visibility.NotificationSurface +import se.ajpanton.notificationsmaster.visibility.VisibilityNotification +import java.lang.reflect.Field +import java.lang.reflect.Method +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) { + 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 + 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), + ) + installMetadataCapture(classLoader, entryClass, modelClass) + installFlowRefresh(classLoader) + installStatusBarFilter(classLoader, modelClass, modelAccess) + }.onSuccess { + Log.i(TAG, "Installed AOSP 36 unlocked notification-icon backend") + }.onFailure { + Log.w(TAG, "AOSP 36 unlocked icon shape is unsupported; leaving icons unchanged", it) + } + } + + @Synchronized fun onPolicyChanged() { + val signal = policyChanges ?: return + policyRevision += 1 + runCatching { + signal.javaClass.methods.single { it.name == "setValue" && it.parameterTypes.size == 1 } + .invoke(signal, policyRevision) + }.onFailure { Log.w(TAG, "Could not refresh unlocked notification icons", it) } + } + + private fun installMetadataCapture(classLoader: ClassLoader, entryClass: Class<*>, modelClass: Class<*>) { + val builder = Class.forName(ACTIVE_STORE_BUILDER, false, classLoader) + val method = builder.declaredMethods.singleOrNull { + it.name == "toModel" && it.returnType == modelClass && it.parameterTypes.contentEquals(arrayOf(entryClass)) + } ?: error("Active notification conversion method not found") + val sbnField = entryClass.requiredField("mSbn", StatusBarNotification::class.java) + method.isAccessible = true + framework.hook(method).intercept { chain -> + val result = chain.proceed() + runCatching { sbnField.get(chain.args[0]) as StatusBarNotification } + .map(::visibilityNotification) + .onSuccess { metadata[it.key] = it } + .onFailure { + if (!captureFailureLogged) { + captureFailureLogged = true + Log.w(TAG, "Notification metadata capture failed; text exceptions will fail open", it) + } + } + result + } + } + + private fun installFlowRefresh(classLoader: ClassLoader) { + val interactor = Class.forName(STATUS_BAR_INTERACTOR, false, classLoader) + val flowField = interactor.requiredField("statusBarNotifs", null) + require(flowField.type.name == "kotlinx.coroutines.flow.Flow") + val flowClass = Class.forName("kotlinx.coroutines.flow.Flow", false, classLoader) + val functionClass = Class.forName("kotlin.jvm.functions.Function3", false, classLoader) + 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( + flowClass.name, + flowClass.name, + functionClass.name, + ) + } + .also { it.isAccessible = true } + val keepUpstreamValue = Proxy.newProxyInstance(classLoader, arrayOf(functionClass)) { proxy, method, args -> + when (method.name) { + "invoke" -> args?.get(0) + "toString" -> "NotificationsMasterVisibilityRefresh" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.get(0) + else -> error("Unexpected Function3 method ${method.name}") + } + } + policyChanges = signal + val constructor = interactor.declaredConstructors.singleOrNull { + it.parameterTypes.map { type -> type.name } == listOf( + "kotlin.coroutines.CoroutineContext", + "com.android.systemui.statusbar.notification.icon.domain.interactor.NotificationIconsInteractor", + "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 + } + } + + private fun installStatusBarFilter( + classLoader: ClassLoader, + modelClass: Class<*>, + access: ModelAccess, + ) { + val collector = Class.forName(STATUS_BAR_MAP_COLLECTOR, false, classLoader) + 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) + if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1])) + } + } + + private fun filter(value: Any?, modelClass: Class<*>, access: ModelAccess): 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() + 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 captured = metadata[key] + val hasExceptions = ProcessVisibilityPolicyCache.hasExceptions(packageName) + if (hasExceptions && captured == null) missingMetadata += 1 + val notification = captured ?: VisibilityNotification(packageName, key) + val blocked = (!hasExceptions || captured != null) && + ProcessVisibilityPolicyCache.isBlocked(notification, NotificationSurface.UNLOCKED_STATUSBAR) + changed = changed || blocked + !blocked + } + val hidden = entries.size - filtered.size + val signature = ((entries.size * 31L + hidden) * 31L + missingMetadata) + if (signature != lastFilterSignature) { + lastFilterSignature = signature + Log.i( + TAG, + "Unlocked notification icons: input=${entries.size}, hidden=$hidden, " + + "missingMetadata=$missingMetadata", + ) + } + return if (changed) filtered else value + } + + private data class ModelAccess(val key: Field, val packageName: Field) + + private fun Class<*>.requiredField(name: String, type: Class<*>?): Field = + getDeclaredField(name).also { + require(type == null || it.type == type) { "Unexpected $name field type" } + it.isAccessible = true + } + + 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 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_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 = + "com.android.systemui.statusbar.notification.icon.ui.viewmodel." + + "NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$1\$2" + 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 bcc2da9..9f57cd7 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -11,10 +11,17 @@ 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 + override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) { - if (param.packageName in VisibilityPolicySync.TARGET_PACKAGES) { - ProcessVisibilityPolicyCache.installWhenReady() - } + if (param.packageName !in VisibilityPolicySync.TARGET_PACKAGES) return + if (param.packageName == SYSTEM_UI_PACKAGE) { + if (systemUiBackend != null) return + val backend = Aosp36UnlockedIconBackend(this) + backend.install(param.classLoader) + systemUiBackend = backend + ProcessVisibilityPolicyCache.installWhenReady(backend::onPolicyChanged) + } else ProcessVisibilityPolicyCache.installWhenReady() } override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) { @@ -55,6 +62,7 @@ class NotificationsMasterModule : XposedModule() { private companion object { const val TAG = "NotificationsMaster" + const val SYSTEM_UI_PACKAGE = "com.android.systemui" const val TEST_HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper" } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt index a67ae6e..a7daed7 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt @@ -30,6 +30,8 @@ internal object ProcessVisibilityPolicyCache { fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface) = policy.isBlocked(notification, surface) + fun hasExceptions(packageName: String) = policy.hasExceptions(packageName) + val unlockedIconLimit get() = policy.unlockedIconLimit private fun tryInstall() { diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt index b069c42..71c19fa 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt @@ -67,6 +67,8 @@ class CompiledVisibilityPolicy(policy: VisibilityPolicy) { return surface in (exception?.blockedSurfaces ?: policy.blockedSurfaces) } + fun hasExceptions(packageName: String) = apps[packageName]?.exceptions?.isNotEmpty() == true + private fun VisibilityNotification.matchText() = buildList { add(packageName) add(key)