Filter native lockscreen and AOD surfaces

This commit is contained in:
ajp_anton
2026-08-29 13:40:54 +00:00
parent 47fbda8a5e
commit cab90de5af
9 changed files with 454 additions and 56 deletions
@@ -16,7 +16,7 @@ class VisibilityModuleFixtureTest {
requireFixtureMode() requireFixtureMode()
store.save(VisibilityPolicy(apps = listOf(AppVisibilityPolicy( store.save(VisibilityPolicy(apps = listOf(AppVisibilityPolicy(
packageName = HELPER_PACKAGE, packageName = HELPER_PACKAGE,
blockedSurfaces = setOf(NotificationSurface.UNLOCKED_STATUSBAR), blockedSurfaces = NotificationSurface.entries.toSet(),
exceptions = listOf(VisibilityExceptionRule("Edited message", emptySet())), exceptions = listOf(VisibilityExceptionRule("Edited message", emptySet())),
)))) ))))
} }
@@ -10,16 +10,18 @@ import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import java.lang.reflect.Method import java.lang.reflect.Method
import java.util.Collections import java.util.Collections
import java.util.WeakHashMap 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. */ /** API 34 uses distinct mapper IDs for its unlocked and AOD icon containers. */
internal class Aosp34UnlockedIconBackend(private val framework: XposedInterface) : UnlockedIconBackend { internal class Aosp34IconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val controllers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>()) private val controllers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private lateinit var updateStatusBarIcons: Method private lateinit var updateStatusBarIcons: Method
@Volatile private var blockedObserved = false private var updateAodNotificationIcons: Method? = null
private val blockedSurfaces = ConcurrentHashMap.newKeySet<NotificationSurface>()
override fun install(classLoader: ClassLoader) { override fun install(classLoader: ClassLoader): Boolean {
if (Build.VERSION.SDK_INT != 34) return if (Build.VERSION.SDK_INT != 34) return false
runCatching { return runCatching {
val controller = Class.forName(CONTROLLER, false, classLoader) val controller = Class.forName(CONTROLLER, false, classLoader)
val mapper = Class.forName(ICON_MAPPER, false, classLoader) val mapper = Class.forName(ICON_MAPPER, false, classLoader)
val entry = Class.forName(NOTIFICATION_ENTRY, 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 { updateStatusBarIcons = controller.declaredMethods.singleOrNull {
it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE it.name == "updateStatusBarIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
} ?: error("Unlocked icon refresh method not found") } ?: error("Unlocked icon refresh method not found")
updateAodNotificationIcons = controller.declaredMethods.singleOrNull {
it.name == "updateAodNotificationIcons" && it.parameterTypes.isEmpty() && it.returnType == Void.TYPE
}
apply.isAccessible = true apply.isAccessible = true
updateStatusBarIcons.isAccessible = true updateStatusBarIcons.isAccessible = true
framework.hook(updateStatusBarIcons).intercept { chain -> updateAodNotificationIcons?.isAccessible = true
synchronized(controllers) { controllers += chain.thisObject } listOfNotNull(updateStatusBarIcons, updateAodNotificationIcons).forEach { refresh ->
chain.proceed() framework.hook(refresh).intercept { chain ->
synchronized(controllers) { controllers += chain.thisObject }
chain.proceed()
}
} }
framework.hook(apply).intercept { chain -> 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 { val notification = runCatching {
VisibilityNotificationExtractor.from(sbn.get(chain.args[0]) as StatusBarNotification) VisibilityNotificationExtractor.from(sbn.get(chain.args[0]) as StatusBarNotification)
}.getOrNull() ?: return@intercept chain.proceed() }.getOrNull() ?: return@intercept chain.proceed()
if (!ProcessVisibilityPolicyCache.isBlocked( if (!ProcessVisibilityPolicyCache.isBlocked(
notification, notification,
NotificationSurface.UNLOCKED_STATUSBAR, surface,
)) return@intercept chain.proceed() )) return@intercept chain.proceed()
if (!blockedObserved) { if (blockedSurfaces.add(surface)) {
blockedObserved = true Log.i(TAG, "Filtered an API 34 ${surface.name} notification icon")
Log.i(TAG, "Filtered an API 34 unlocked notification icon")
} }
null null
} }
@@ -62,22 +75,25 @@ internal class Aosp34UnlockedIconBackend(private val framework: XposedInterface)
Log.i(TAG, "Installed AOSP API 34 unlocked notification-icon backend") Log.i(TAG, "Installed AOSP API 34 unlocked notification-icon backend")
}.onFailure { }.onFailure {
Log.w(TAG, "AOSP API 34 unlocked icon shape is unsupported; leaving icons unchanged", it) Log.w(TAG, "AOSP API 34 unlocked icon shape is unsupported; leaving icons unchanged", it)
} }.isSuccess
} }
override fun onPolicyChanged() { override fun onPolicyChanged() {
blockedObserved = false blockedSurfaces.clear()
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
val current = synchronized(controllers) { controllers.toList() } val current = synchronized(controllers) { controllers.toList() }
current.forEach { controller -> current.forEach { controller ->
runCatching { updateStatusBarIcons.invoke(controller) } runCatching {
.onFailure { Log.w(TAG, "Could not refresh API 34 unlocked icons", it) } updateStatusBarIcons.invoke(controller)
updateAodNotificationIcons?.invoke(controller)
}.onFailure { Log.w(TAG, "Could not refresh API 34 notification icons", it) }
} }
} }
} }
private companion object { private companion object {
const val TAG = "NotificationsMaster" const val TAG = "NotificationsMaster"
const val AOD_CLASS_ID = 0
const val STATUS_BAR_CLASS_ID = 1 const val STATUS_BAR_CLASS_ID = 1
const val CONTROLLER = "com.android.systemui.statusbar.phone.NotificationIconAreaController" const val CONTROLLER = "com.android.systemui.statusbar.phone.NotificationIconAreaController"
const val ICON_MAPPER = "$CONTROLLER\$\$ExternalSyntheticLambda2" const val ICON_MAPPER = "$CONTROLLER\$\$ExternalSyntheticLambda2"
@@ -12,17 +12,18 @@ import java.lang.reflect.Proxy
import java.util.LinkedHashSet import java.util.LinkedHashSet
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** Filters only the modern status-bar icon flow; the notification/shade model is untouched. */ /** Filters the modern unlocked and AOD icon flows; the notification/shade model is untouched. */
internal class AospModernUnlockedIconBackend(private val framework: XposedInterface) : UnlockedIconBackend { internal class AospModernIconBackend(private val framework: XposedInterface) : VisibilitySurfaceBackend {
private val metadata = ConcurrentHashMap<String, VisibilityNotification>() private val metadata = ConcurrentHashMap<String, VisibilityNotification>()
private val activeKeys = ConcurrentHashMap<NotificationSurface, Set<String>>()
@Volatile private var policyChanges: Any? = null @Volatile private var policyChanges: Any? = null
private var policyRevision = 0L private var policyRevision = 0L
@Volatile private var lastFilterSignature = -1L private val lastFilterSignatures = ConcurrentHashMap<NotificationSurface, Long>()
@Volatile private var captureFailureLogged = false @Volatile private var captureFailureLogged = false
override fun install(classLoader: ClassLoader) { override fun install(classLoader: ClassLoader): Boolean {
if (Build.VERSION.SDK_INT !in 35..36) return if (Build.VERSION.SDK_INT !in 35..36) return false
runCatching { return runCatching {
val entryClass = Class.forName(NOTIFICATION_ENTRY, false, classLoader) val entryClass = Class.forName(NOTIFICATION_ENTRY, false, classLoader)
val modelClass = Class.forName(ACTIVE_NOTIFICATION_MODEL, false, classLoader) val modelClass = Class.forName(ACTIVE_NOTIFICATION_MODEL, false, classLoader)
val modelAccesses = buildList { val modelAccesses = buildList {
@@ -39,11 +40,12 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf
installMetadataCapture(classLoader, entryClass, modelClass) installMetadataCapture(classLoader, entryClass, modelClass)
installFlowRefresh(classLoader) installFlowRefresh(classLoader)
installStatusBarFilter(classLoader, modelAccesses) installStatusBarFilter(classLoader, modelAccesses)
installAodFilter(classLoader, modelAccesses)
}.onSuccess { }.onSuccess {
Log.i(TAG, "Installed AOSP API ${Build.VERSION.SDK_INT} unlocked notification-icon backend") Log.i(TAG, "Installed AOSP API ${Build.VERSION.SDK_INT} unlocked notification-icon backend")
}.onFailure { }.onFailure {
Log.w(TAG, "AOSP API ${Build.VERSION.SDK_INT} 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)
} }.isSuccess
} }
@Synchronized override fun onPolicyChanged() { @Synchronized override fun onPolicyChanged() {
@@ -142,6 +144,9 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf
else -> error("Unexpected Function3 method ${method.name}") 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 policyChanges = signal
val constructor = interactor.declaredConstructors.singleOrNull { val constructor = interactor.declaredConstructors.singleOrNull {
it.parameterTypes.map { type -> type.name } == listOf( it.parameterTypes.map { type -> type.name } == listOf(
@@ -164,17 +169,24 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf
framework.hook(target).intercept { chain -> framework.hook(target).intercept { chain ->
val result = chain.proceed() val result = chain.proceed()
runCatching { runCatching {
val upstream = field.get(chain.thisObject) field.set(chain.thisObject, refreshed(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) } }.onFailure { Log.w(TAG, "Could not attach visibility refresh flow; leaving icons unchanged", it) }
result 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( private fun installStatusBarFilter(
@@ -189,19 +201,42 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf
emit.isAccessible = true emit.isAccessible = true
framework.hook(emit).intercept { chain -> framework.hook(emit).intercept { chain ->
val original = chain.args[0] 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])) if (filtered === original) chain.proceed() else chain.proceed(arrayOf(filtered, chain.args[1]))
} }
} }
private fun filter(value: Any?, modelAccesses: List<ModelAccess>): Any? { private fun installAodFilter(classLoader: ClassLoader, modelAccesses: List<ModelAccess>) {
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<ModelAccess>,
surface: NotificationSurface,
): Any? {
val entries = value as? Set<*> ?: return value val entries = value as? Set<*> ?: return value
if (entries.any { model -> model != null && modelAccesses.none { it.type.isInstance(model) } }) 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) } 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 } model?.let { access(it).key.get(it) as? String }
}.toSet() }.toSet()
metadata.keys.retainAll(activeKeys) activeKeys[surface] = surfaceKeys
metadata.keys.retainAll(activeKeys.values.flatten().toSet())
var changed = false var changed = false
var missingMetadata = 0 var missingMetadata = 0
val filtered = entries.filterTo(LinkedHashSet(entries.size)) { model -> 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 if (hasExceptions && captured == null) missingMetadata += 1
val notification = captured ?: VisibilityNotification(packageName, key) val notification = captured ?: VisibilityNotification(packageName, key)
val blocked = (!hasExceptions || captured != null) && val blocked = (!hasExceptions || captured != null) &&
ProcessVisibilityPolicyCache.isBlocked(notification, NotificationSurface.UNLOCKED_STATUSBAR) ProcessVisibilityPolicyCache.isBlocked(notification, surface)
changed = changed || blocked changed = changed || blocked
!blocked !blocked
} }
val hidden = entries.size - filtered.size val hidden = entries.size - filtered.size
val signature = ((entries.size * 31L + hidden) * 31L + missingMetadata) val signature = ((entries.size * 31L + hidden) * 31L + missingMetadata)
if (signature != lastFilterSignature) { if (lastFilterSignatures.put(surface, signature) != signature) {
lastFilterSignature = signature
Log.i( Log.i(
TAG, TAG,
"Unlocked notification icons: input=${entries.size}, hidden=$hidden, " + "${surface.name} notification icons: input=${entries.size}, hidden=$hidden, " +
"missingMetadata=$missingMetadata", "missingMetadata=$missingMetadata",
) )
} }
@@ -262,6 +296,9 @@ internal class AospModernUnlockedIconBackend(private val framework: XposedInterf
"com.android.systemui.statusbar.notification.icon.domain.interactor.StatusBarNotificationIconsInteractor" "com.android.systemui.statusbar.notification.icon.domain.interactor.StatusBarNotificationIconsInteractor"
const val STATUS_BAR_VIEW_MODEL = const val STATUS_BAR_VIEW_MODEL =
"com.android.systemui.statusbar.notification.icon.ui.viewmodel.NotificationIconContainerStatusBarViewModel" "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 = const val STATUS_BAR_MAP_COLLECTOR_PREFIX =
"com.android.systemui.statusbar.notification.icon.ui.viewmodel." + "com.android.systemui.statusbar.notification.icon.ui.viewmodel." +
"NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$" "NotificationIconContainerStatusBarViewModel\$special\$\$inlined\$map\$"
@@ -11,19 +11,31 @@ import io.github.libxposed.api.XposedModuleInterface
* in a compatible framework and grants it a system-server scope. * in a compatible framework and grants it a system-server scope.
*/ */
class NotificationsMasterModule : XposedModule() { class NotificationsMasterModule : XposedModule() {
private var systemUiBackend: UnlockedIconBackend? = null private var systemUiBackends: List<VisibilitySurfaceBackend>? = null
private var aodBackends: List<VisibilitySurfaceBackend>? = null
override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) { override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) {
if (param.packageName !in VisibilityPolicySync.TARGET_PACKAGES) return if (param.packageName !in VisibilityPolicySync.TARGET_PACKAGES) return
if (param.packageName == SYSTEM_UI_PACKAGE) { if (param.packageName == SYSTEM_UI_PACKAGE) {
if (systemUiBackend != null) return if (systemUiBackends != null) return
val backend = if (android.os.Build.VERSION.SDK_INT == 34) { val unlocked = listOf(
Aosp34UnlockedIconBackend(this) OneUiUnlockedIconBackend(this),
} else AospModernUnlockedIconBackend(this) Aosp34IconBackend(this),
backend.install(param.classLoader) AospModernIconBackend(this),
systemUiBackend = backend ).firstOrNull { it.install(param.classLoader) }
ProcessVisibilityPolicyCache.installWhenReady(backend::onPolicyChanged) systemUiBackends = listOfNotNull(unlocked) + listOf(
} else ProcessVisibilityPolicyCache.installWhenReady() 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) { override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
@@ -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<Any, Boolean>())
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"
}
}
@@ -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<Any, Boolean>())
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<Any, Any?> ?: return@intercept chain.proceed()
val wrapped = Function<Any, Any?> { 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"
}
}
@@ -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<Any, State>())
private val refreshing = ThreadLocal<Boolean>()
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<StatusBarNotification>,
): 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<StatusBarNotification>): List<StatusBarNotification> {
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<StatusBarNotification>? {
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<StatusBarNotification>)
}
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<StatusBarNotification>? = null,
var visible: VisibleState? = null,
)
private data class VisibleState(
val first: List<StatusBarNotification>,
val second: List<StatusBarNotification>,
val type: Int,
)
private companion object {
const val TAG = "NotificationsMaster"
const val MANAGER = "com.samsung.android.uniform.plugins.notification.AODNotificationManager"
}
}
@@ -1,6 +0,0 @@
package se.ajpanton.notificationsmaster.module
internal interface UnlockedIconBackend {
fun install(classLoader: ClassLoader)
fun onPolicyChanged()
}
@@ -0,0 +1,6 @@
package se.ajpanton.notificationsmaster.module
internal interface VisibilitySurfaceBackend {
fun install(classLoader: ClassLoader): Boolean
fun onPolicyChanged()
}