Add notification visibility policy bridge

This commit is contained in:
ajp_anton
2026-08-29 12:19:54 +00:00
parent 44357bf047
commit a3063d0c9a
16 changed files with 557 additions and 4 deletions
+4
View File
@@ -53,6 +53,10 @@
</intent-filter>
</receiver>
<receiver
android:name=".module.VisibilityPolicyRequestReceiver"
android:exported="true" />
</application>
</manifest>
@@ -3,6 +3,7 @@ package se.ajpanton.notificationsmaster
import android.app.Application
import android.content.pm.PackageManager
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
@@ -11,6 +12,7 @@ class NotificationLogApplication : Application() {
override fun onCreate() {
super.onCreate()
synchronizeListenerComponent()
VisibilityPolicySync.publishStored(this)
Thread(::removeStaleAppStorage, "notification-log-storage-cleanup").start()
}
@@ -46,10 +46,11 @@ internal object AlertPolicySync {
private const val TAG = "NotificationsMaster"
}
/** Re-publishes active alert policy after boot, without starting a service. */
/** Re-publishes process-local policies after boot, without starting a service. */
class AlertPolicyBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
AlertPolicySync.publish(context, se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore(context).load())
VisibilityPolicySync.publishStored(context)
}
}
@@ -11,6 +11,12 @@ import io.github.libxposed.api.XposedModuleInterface
* in a compatible framework and grants it a system-server scope.
*/
class NotificationsMasterModule : XposedModule() {
override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) {
if (param.packageName in VisibilityPolicySync.TARGET_PACKAGES) {
ProcessVisibilityPolicyCache.installWhenReady()
}
}
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
SystemAlertPolicyCache.installWhenReady()
Handler(Looper.getMainLooper()).post {
@@ -0,0 +1,104 @@
package se.ajpanton.notificationsmaster.module
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import android.os.Looper
import android.util.Log
import se.ajpanton.notificationsmaster.visibility.CompiledVisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.NotificationSurface
import se.ajpanton.notificationsmaster.visibility.VisibilityNotification
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyJson
/** Process-local policy used by SystemUI/AOD hooks without entering the app process. */
internal object ProcessVisibilityPolicyCache {
@Volatile
private var policy = CompiledVisibilityPolicy.ALLOW_ALL
private var installed = false
private var attempts = 0
private var onChanged: () -> Unit = {}
fun installWhenReady(onChanged: () -> Unit = {}) {
this.onChanged = onChanged
Handler(Looper.getMainLooper()).post(::tryInstall)
}
fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface) =
policy.isBlocked(notification, surface)
val unlockedIconLimit get() = policy.unlockedIconLimit
private fun tryInstall() {
if (installed) return
val context = currentApplicationContext()
if (context == null || context.packageName !in VisibilityPolicySync.TARGET_PACKAGES) {
retryOrGiveUp()
return
}
runCatching {
context.registerReceiver(
PolicyReceiver(),
IntentFilter(VisibilityPolicySync.UPDATE_ACTION),
VisibilityPolicySync.PERMISSION,
null,
Context.RECEIVER_EXPORTED,
)
context.sendBroadcast(
Intent(VisibilityPolicySync.REQUEST_ACTION).setComponent(
ComponentName(VisibilityPolicySync.APP_PACKAGE, VisibilityPolicySync.REQUEST_RECEIVER),
).putExtra(
VisibilityPolicySync.EXTRA_REQUESTER,
PendingIntent.getBroadcast(
context,
0,
Intent(REQUESTER_IDENTITY_ACTION).setPackage(context.packageName),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
),
)
}.onSuccess {
installed = true
Log.i(TAG, "Installed visibility-policy cache in ${context.packageName}; requested replay")
}.onFailure {
retryOrGiveUp()
}
}
private fun currentApplicationContext(): Context? = runCatching {
Class.forName("android.app.ActivityThread")
.getMethod("currentApplication")
.invoke(null) as? Context
}.getOrNull()
private fun retryOrGiveUp() {
if (++attempts < MAX_ATTEMPTS) {
Handler(Looper.getMainLooper()).postDelayed(::tryInstall, RETRY_DELAY_MILLIS)
} else {
Log.w(TAG, "Visibility-policy cache unavailable; allowing all notifications")
}
}
private class PolicyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val payload = intent.getByteArrayExtra(VisibilityPolicySync.EXTRA_POLICY)
val next = if (payload == null || payload.size > VisibilityPolicySync.MAX_POLICY_BYTES) null else {
runCatching { CompiledVisibilityPolicy(VisibilityPolicyJson.decode(payload)) }
.onFailure { Log.w(TAG, "Rejected malformed visibility policy; allowing all", it) }
.getOrNull()
}
if (next != null && next.generation < policy.generation) return
policy = next ?: CompiledVisibilityPolicy.ALLOW_ALL
Log.i(TAG, "Visibility policy cache generation ${policy.generation}")
onChanged()
}
}
private const val TAG = "NotificationsMaster"
private const val REQUESTER_IDENTITY_ACTION = "se.ajpanton.notificationsmaster.VISIBILITY_REQUESTER_IDENTITY"
private const val MAX_ATTEMPTS = 10
private const val RETRY_DELAY_MILLIS = 1_000L
}
@@ -0,0 +1,63 @@
package se.ajpanton.notificationsmaster.module
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicy
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyJson
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
internal object VisibilityPolicySync {
const val UPDATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_VISIBILITY_POLICY"
const val REQUEST_ACTION = "se.ajpanton.notificationsmaster.REQUEST_VISIBILITY_POLICY"
const val EXTRA_POLICY = "policy"
const val EXTRA_REQUESTER = "requester"
const val PERMISSION = AlertPolicySync.PERMISSION
const val APP_PACKAGE = "se.ajpanton.notificationsmaster"
const val REQUEST_RECEIVER = "$APP_PACKAGE.module.VisibilityPolicyRequestReceiver"
const val MAX_POLICY_BYTES = 128 * 1024
val TARGET_PACKAGES = setOf("com.android.systemui", "com.samsung.android.app.aodservice")
fun publish(context: Context, policy: VisibilityPolicy) {
val payload = VisibilityPolicyJson.encode(policy)
if (payload.size > MAX_POLICY_BYTES) {
Log.w(TAG, "Visibility policy exceeds the process-cache limit; allowing all notifications")
return
}
TARGET_PACKAGES.forEach { target ->
context.sendBroadcast(Intent(UPDATE_ACTION).setPackage(target).putExtra(EXTRA_POLICY, payload))
}
Log.i(TAG, "Published visibility policy generation ${policy.generation}")
}
fun publishStored(context: Context) {
val policy = runCatching { VisibilityPolicyStore(context).load() }
.onFailure { Log.e(TAG, "Stored visibility policy is unreadable; allowing all notifications", it) }
.getOrElse { VisibilityPolicy(enabled = false) }
publish(context, policy)
}
private const val TAG = "NotificationsMaster"
}
class VisibilityPolicyRequestReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != VisibilityPolicySync.REQUEST_ACTION) return
val requester = intent.getParcelableExtra(VisibilityPolicySync.EXTRA_REQUESTER, PendingIntent::class.java)
val creatorPackages = requester?.let {
context.packageManager.getPackagesForUid(it.creatorUid).orEmpty().toSet()
}.orEmpty()
val creatorPackage = requester?.creatorPackage
if (creatorPackage !in VisibilityPolicySync.TARGET_PACKAGES ||
creatorPackage !in creatorPackages
) {
Log.w(TAG, "Rejected visibility-policy request without a trusted identity")
return
}
VisibilityPolicySync.publishStored(context)
}
private companion object { const val TAG = "NotificationsMaster" }
}
@@ -8,6 +8,7 @@ import android.util.Log
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.module.AlertPolicyBootReceiver
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
/**
* Notification listeners are re-bound by Android after boot when their component
@@ -23,6 +24,7 @@ internal object NotificationListenerComponentController {
LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
AlertConfigurationStore(applicationContext).load().needsListener,
VisibilityPolicyStore(applicationContext).load().hasRules,
)
}
@@ -31,11 +33,17 @@ internal object NotificationListenerComponentController {
rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
hasEnabledAlertRule: Boolean = false,
hasVisibilityRules: Boolean = false,
) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride, hasEnabledAlertRule)) {
val desired = if (NotificationListenerPolicy.shouldRun(
rules,
hasEnabledEventOverride,
hasEnabledAlertRule,
hasVisibilityRules,
)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
@@ -44,7 +52,7 @@ internal object NotificationListenerComponentController {
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
}
val bootReceiver = ComponentName(applicationContext, AlertPolicyBootReceiver::class.java)
val bootReceiverState = if (hasEnabledAlertRule) {
val bootReceiverState = if (hasEnabledAlertRule || hasVisibilityRules) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
@@ -6,7 +6,9 @@ object NotificationListenerPolicy {
rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
hasEnabledAlertRule: Boolean = false,
hasVisibilityRules: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
hasEnabledEventOverride ||
hasEnabledAlertRule
hasEnabledAlertRule ||
hasVisibilityRules
}
@@ -0,0 +1,90 @@
package se.ajpanton.notificationsmaster.visibility
import com.google.re2j.Pattern
enum class NotificationSurface { AOD, LOCKSCREEN_COLLAPSED, UNLOCKED_STATUSBAR }
data class VisibilityExceptionRule(
val pattern: String,
val blockedSurfaces: Set<NotificationSurface>,
) {
init { require(pattern.isNotBlank()) }
fun hasValidPattern() = runCatching { Pattern.compile(pattern) }.isSuccess
}
data class AppVisibilityPolicy(
val packageName: String,
val blockedSurfaces: Set<NotificationSurface> = emptySet(),
val exceptions: List<VisibilityExceptionRule> = emptyList(),
) {
init { require(packageName.isNotBlank()) }
}
data class VisibilityPolicy(
val generation: Long = 0,
val enabled: Boolean = true,
val apps: List<AppVisibilityPolicy> = emptyList(),
val unlockedIconLimit: Int? = null,
) {
init {
require(generation >= 0)
require(apps.map { it.packageName }.distinct().size == apps.size)
require(unlockedIconLimit == null || unlockedIconLimit in 1..1000)
}
val hasRules get() = enabled && (unlockedIconLimit != null ||
apps.any { it.blockedSurfaces.isNotEmpty() || it.exceptions.isNotEmpty() })
}
data class VisibilityNotification(
val packageName: String,
val key: String,
val title: String? = null,
val text: String? = null,
val bigText: String? = null,
val subtext: String? = null,
val messages: List<String> = emptyList(),
val channelId: String? = null,
)
class CompiledVisibilityPolicy(policy: VisibilityPolicy) {
val generation = policy.generation
val unlockedIconLimit = policy.unlockedIconLimit
private val enabled = policy.enabled
private val apps = policy.apps.associate { app ->
app.packageName to CompiledAppPolicy(
app.blockedSurfaces,
app.exceptions.map { CompiledRule(Pattern.compile(it.pattern), it.blockedSurfaces) },
)
}
fun isBlocked(notification: VisibilityNotification, surface: NotificationSurface): Boolean {
if (!enabled) return false
val policy = apps[notification.packageName] ?: return false
val matchText = notification.matchText()
val exception = policy.exceptions.firstOrNull { it.pattern.matcher(matchText).find() }
return surface in (exception?.blockedSurfaces ?: policy.blockedSurfaces)
}
private fun VisibilityNotification.matchText() = buildList {
add(packageName)
add(key)
listOf(title, text, bigText, subtext, channelId).filterNotNullTo(this)
addAll(messages)
}.joinToString("\n")
private data class CompiledAppPolicy(
val blockedSurfaces: Set<NotificationSurface>,
val exceptions: List<CompiledRule>,
)
private data class CompiledRule(
val pattern: Pattern,
val blockedSurfaces: Set<NotificationSurface>,
)
companion object {
val ALLOW_ALL = CompiledVisibilityPolicy(VisibilityPolicy(enabled = false))
}
}
@@ -0,0 +1,50 @@
package se.ajpanton.notificationsmaster.visibility
import org.json.JSONArray
import org.json.JSONObject
internal object VisibilityPolicyJson {
const val SCHEMA_VERSION = 1
fun encode(policy: VisibilityPolicy): ByteArray = JSONObject()
.put("version", SCHEMA_VERSION)
.put("generation", policy.generation)
.put("enabled", policy.enabled)
.put("unlockedIconLimit", policy.unlockedIconLimit ?: JSONObject.NULL)
.put("apps", JSONArray().apply { policy.apps.forEach { put(it.toJson()) } })
.toString().encodeToByteArray()
fun decode(bytes: ByteArray): VisibilityPolicy {
val root = JSONObject(bytes.decodeToString())
require(root.getInt("version") == SCHEMA_VERSION)
return VisibilityPolicy(
generation = root.getLong("generation"),
enabled = root.getBoolean("enabled"),
apps = root.getJSONArray("apps").map { (it as JSONObject).toAppPolicy() },
unlockedIconLimit = if (root.isNull("unlockedIconLimit")) null else root.getInt("unlockedIconLimit"),
)
}
private fun AppVisibilityPolicy.toJson() = JSONObject()
.put("packageName", packageName)
.put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name }))
.put("exceptions", JSONArray().apply { exceptions.forEach { put(it.toJson()) } })
private fun VisibilityExceptionRule.toJson() = JSONObject()
.put("pattern", pattern)
.put("blockedSurfaces", JSONArray(blockedSurfaces.map { it.name }))
private fun JSONObject.toAppPolicy() = AppVisibilityPolicy(
packageName = getString("packageName"),
blockedSurfaces = getJSONArray("blockedSurfaces").surfaces(),
exceptions = getJSONArray("exceptions").map { (it as JSONObject).toException() },
)
private fun JSONObject.toException() = VisibilityExceptionRule(
pattern = getString("pattern"),
blockedSurfaces = getJSONArray("blockedSurfaces").surfaces(),
)
private fun JSONArray.surfaces() = map { NotificationSurface.valueOf(it as String) }.toSet()
private fun <T> JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) }
}
@@ -0,0 +1,65 @@
package se.ajpanton.notificationsmaster.visibility
import android.content.Context
import android.util.AtomicFile
import se.ajpanton.notificationsmaster.data.AesGcmCipher
import se.ajpanton.notificationsmaster.data.EncryptedPayload
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileNotFoundException
class VisibilityPolicyStore(context: Context) {
private val context = context.applicationContext
private val file = AtomicFile(File(this.context.filesDir, FILE_NAME))
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
@Synchronized
fun load(): VisibilityPolicy = try {
DataInputStream(BufferedInputStream(file.openRead())).use { input ->
val iv = ByteArray(input.readInt().also { require(it in 1..32) })
input.readFully(iv)
val encrypted = ByteArray(input.readInt().also { require(it in 1..MAX_CIPHER_TEXT_BYTES) })
input.readFully(encrypted)
VisibilityPolicyJson.decode(cipher.decrypt(EncryptedPayload(iv, encrypted)))
}
} catch (_: FileNotFoundException) {
VisibilityPolicy()
}
@Synchronized
fun save(policy: VisibilityPolicy): VisibilityPolicy {
require(policy.apps.flatMap { it.exceptions }.all { it.hasValidPattern() })
val stored = policy.copy(generation = load().generation + 1)
val encoded = VisibilityPolicyJson.encode(stored)
require(encoded.size <= VisibilityPolicySync.MAX_POLICY_BYTES) { "Visibility policy is too large." }
val payload = cipher.encrypt(encoded)
val output = file.startWrite()
try {
DataOutputStream(BufferedOutputStream(output)).apply {
writeInt(payload.initializationVector.size)
write(payload.initializationVector)
writeInt(payload.cipherText.size)
write(payload.cipherText)
flush()
}
file.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
throw error
}
NotificationListenerComponentController.synchronize(context)
VisibilityPolicySync.publish(context, stored)
return stored
}
private companion object {
const val FILE_NAME = "visibility-policy.bin"
const val MAX_CIPHER_TEXT_BYTES = 1024 * 1024
}
}
@@ -1,2 +1,4 @@
android
system
com.android.systemui
com.samsung.android.app.aodservice