Filter AOSP 36 unlocked notification icons
This commit is contained in:
+8
@@ -4,21 +4,29 @@ import android.content.ComponentName
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||||
import se.ajpanton.notificationsmaster.settings.LoggingRule
|
import se.ajpanton.notificationsmaster.settings.LoggingRule
|
||||||
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
|
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
|
||||||
import se.ajpanton.notificationsmaster.settings.LoggingType
|
import se.ajpanton.notificationsmaster.settings.LoggingType
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
||||||
|
|
||||||
class AlertListenerPolicyDeviceTest {
|
class AlertListenerPolicyDeviceTest {
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val component = ComponentName(context, NotificationCaptureService::class.java)
|
private val component = ComponentName(context, NotificationCaptureService::class.java)
|
||||||
|
|
||||||
|
@Before fun clearVisibilityRules() {
|
||||||
|
VisibilityPolicyStore(context).save(VisibilityPolicy())
|
||||||
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun restoreDefaults() {
|
fun restoreDefaults() {
|
||||||
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule()) }
|
LoggingType.entries.forEach { LoggingRuleStore(context).save(it, LoggingRule()) }
|
||||||
AlertConfigurationStore(context).save(AlertConfiguration())
|
AlertConfigurationStore(context).save(AlertConfiguration())
|
||||||
|
VisibilityPolicyStore(context).save(VisibilityPolicy())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+34
@@ -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" }
|
||||||
|
}
|
||||||
+2
-1
@@ -3,6 +3,7 @@ package se.ajpanton.notificationsmaster.visibility
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -14,7 +15,7 @@ import java.io.File
|
|||||||
class VisibilityPolicyStoreTest {
|
class VisibilityPolicyStoreTest {
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
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").delete()
|
||||||
File(context.filesDir, "visibility-policy.bin.bak").delete()
|
File(context.filesDir, "visibility-policy.bin.bak").delete()
|
||||||
}
|
}
|
||||||
|
|||||||
+221
@@ -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<String, VisibilityNotification>()
|
||||||
|
@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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -11,10 +11,17 @@ 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: Aosp36UnlockedIconBackend? = null
|
||||||
|
|
||||||
override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) {
|
override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) {
|
||||||
if (param.packageName in VisibilityPolicySync.TARGET_PACKAGES) {
|
if (param.packageName !in VisibilityPolicySync.TARGET_PACKAGES) return
|
||||||
ProcessVisibilityPolicyCache.installWhenReady()
|
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) {
|
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
|
||||||
@@ -55,6 +62,7 @@ class NotificationsMasterModule : XposedModule() {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "NotificationsMaster"
|
const val TAG = "NotificationsMaster"
|
||||||
|
const val SYSTEM_UI_PACKAGE = "com.android.systemui"
|
||||||
const val TEST_HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper"
|
const val TEST_HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -30,6 +30,8 @@ internal object ProcessVisibilityPolicyCache {
|
|||||||
fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface) =
|
fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface) =
|
||||||
policy.isBlocked(notification, surface)
|
policy.isBlocked(notification, surface)
|
||||||
|
|
||||||
|
fun hasExceptions(packageName: String) = policy.hasExceptions(packageName)
|
||||||
|
|
||||||
val unlockedIconLimit get() = policy.unlockedIconLimit
|
val unlockedIconLimit get() = policy.unlockedIconLimit
|
||||||
|
|
||||||
private fun tryInstall() {
|
private fun tryInstall() {
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ class CompiledVisibilityPolicy(policy: VisibilityPolicy) {
|
|||||||
return surface in (exception?.blockedSurfaces ?: policy.blockedSurfaces)
|
return surface in (exception?.blockedSurfaces ?: policy.blockedSurfaces)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasExceptions(packageName: String) = apps[packageName]?.exceptions?.isNotEmpty() == true
|
||||||
|
|
||||||
private fun VisibilityNotification.matchText() = buildList {
|
private fun VisibilityNotification.matchText() = buildList {
|
||||||
add(packageName)
|
add(packageName)
|
||||||
add(key)
|
add(key)
|
||||||
|
|||||||
Reference in New Issue
Block a user