diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt
new file mode 100644
index 0000000..d516dc4
--- /dev/null
+++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt
@@ -0,0 +1,38 @@
+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.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+@RunWith(AndroidJUnit4::class)
+class VisibilityPolicyStoreTest {
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @After fun cleanUp() {
+ File(context.filesDir, "visibility-policy.bin").delete()
+ File(context.filesDir, "visibility-policy.bin.bak").delete()
+ }
+
+ @Test fun encryptedPolicyRoundTripsAndAdvancesGeneration() {
+ val store = VisibilityPolicyStore(context)
+ val original = VisibilityPolicy(apps = listOf(
+ AppVisibilityPolicy("example.app", setOf(NotificationSurface.UNLOCKED_STATUSBAR)),
+ ))
+
+ val first = store.save(original)
+ val second = store.save(first.copy(enabled = false))
+
+ assertEquals(1, first.generation)
+ assertEquals(2, second.generation)
+ assertEquals(second, store.load())
+ val raw = File(context.filesDir, "visibility-policy.bin").readBytes().decodeToString()
+ assertFalse(raw.contains("example.app"))
+ assertTrue(File(context.filesDir, "visibility-policy.bin").length() > 0)
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6ff07ed..c9a33cb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -53,6 +53,10 @@
+
+
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt
index dd342a2..0a2112d 100644
--- a/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/NotificationLogApplication.kt
@@ -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()
}
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt
index 214c6d8..c8345e3 100644
--- a/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/AlertPolicySync.kt
@@ -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)
}
}
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 ea27761..bcc2da9 100644
--- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt
@@ -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 {
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt
new file mode 100644
index 0000000..a67ae6e
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt
@@ -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
+}
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityPolicySync.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityPolicySync.kt
new file mode 100644
index 0000000..9375428
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityPolicySync.kt
@@ -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" }
+}
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt
index ad63acb..40d6a35 100644
--- a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerComponentController.kt
@@ -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,
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
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicy.kt b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicy.kt
index 0e44324..6c33f2b 100644
--- a/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicy.kt
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicy.kt
@@ -6,7 +6,9 @@ object NotificationListenerPolicy {
rules: Map,
hasEnabledEventOverride: Boolean = false,
hasEnabledAlertRule: Boolean = false,
+ hasVisibilityRules: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
hasEnabledEventOverride ||
- hasEnabledAlertRule
+ hasEnabledAlertRule ||
+ hasVisibilityRules
}
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt
new file mode 100644
index 0000000..b069c42
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt
@@ -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,
+) {
+ init { require(pattern.isNotBlank()) }
+
+ fun hasValidPattern() = runCatching { Pattern.compile(pattern) }.isSuccess
+}
+
+data class AppVisibilityPolicy(
+ val packageName: String,
+ val blockedSurfaces: Set = emptySet(),
+ val exceptions: List = emptyList(),
+) {
+ init { require(packageName.isNotBlank()) }
+}
+
+data class VisibilityPolicy(
+ val generation: Long = 0,
+ val enabled: Boolean = true,
+ val apps: List = 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 = 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,
+ val exceptions: List,
+ )
+
+ private data class CompiledRule(
+ val pattern: Pattern,
+ val blockedSurfaces: Set,
+ )
+
+ companion object {
+ val ALLOW_ALL = CompiledVisibilityPolicy(VisibilityPolicy(enabled = false))
+ }
+}
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt
new file mode 100644
index 0000000..6fd5c67
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt
@@ -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 JSONArray.map(transform: (Any) -> T) = List(length()) { transform(get(it)) }
+}
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt
new file mode 100644
index 0000000..c053e43
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt
@@ -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
+ }
+}
diff --git a/app/src/main/resources/META-INF/xposed/scope.list b/app/src/main/resources/META-INF/xposed/scope.list
index b079f01..bae17ab 100644
--- a/app/src/main/resources/META-INF/xposed/scope.list
+++ b/app/src/main/resources/META-INF/xposed/scope.list
@@ -1,2 +1,4 @@
android
system
+com.android.systemui
+com.samsung.android.app.aodservice
diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicyTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicyTest.kt
index eb0559d..adb7142 100644
--- a/app/src/test/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicyTest.kt
+++ b/app/src/test/java/se/ajpanton/notificationsmaster/settings/NotificationListenerPolicyTest.kt
@@ -31,6 +31,10 @@ class NotificationListenerPolicyTest {
assertTrue(NotificationListenerPolicy.shouldRun(rules(), hasEnabledAlertRule = true))
}
+ @Test fun `visibility rules keep seen-app collection active`() {
+ assertTrue(NotificationListenerPolicy.shouldRun(rules(), hasVisibilityRules = true))
+ }
+
private fun rules(vararg enabled: Pair) = LoggingType.entries.associateWith { type ->
LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false)
}
diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt
new file mode 100644
index 0000000..a3ff2db
--- /dev/null
+++ b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt
@@ -0,0 +1,34 @@
+package se.ajpanton.notificationsmaster.visibility
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class VisibilityPolicyJsonTest {
+ @Test fun `policy round trips without changing ordered exceptions`() {
+ val policy = VisibilityPolicy(
+ generation = 17,
+ enabled = true,
+ unlockedIconLimit = 7,
+ apps = listOf(AppVisibilityPolicy(
+ packageName = "example.app",
+ blockedSurfaces = setOf(NotificationSurface.AOD),
+ exceptions = listOf(
+ VisibilityExceptionRule("first", setOf(NotificationSurface.LOCKSCREEN_COLLAPSED)),
+ VisibilityExceptionRule("second", emptySet()),
+ ),
+ )),
+ )
+
+ assertEquals(policy, VisibilityPolicyJson.decode(VisibilityPolicyJson.encode(policy)))
+ }
+
+ @Test fun `nullable icon limit round trips`() {
+ val policy = VisibilityPolicy()
+ assertEquals(policy, VisibilityPolicyJson.decode(VisibilityPolicyJson.encode(policy)))
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `unknown schemas are rejected`() {
+ VisibilityPolicyJson.decode("{\"version\":99}".encodeToByteArray())
+ }
+}
diff --git a/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyTest.kt b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyTest.kt
new file mode 100644
index 0000000..ebfba63
--- /dev/null
+++ b/app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyTest.kt
@@ -0,0 +1,80 @@
+package se.ajpanton.notificationsmaster.visibility
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class VisibilityPolicyTest {
+ private val notification = VisibilityNotification(
+ packageName = "example.messages",
+ key = "0|example.messages|42|null|1000",
+ title = "Alice",
+ text = "Lunch tomorrow?",
+ bigText = "Lunch tomorrow at noon?",
+ subtext = "Personal",
+ messages = listOf("Alice: Lunch tomorrow at noon?"),
+ channelId = "messages",
+ )
+
+ @Test fun `unknown apps and disabled policies allow every surface`() {
+ val unknown = CompiledVisibilityPolicy(VisibilityPolicy(
+ apps = listOf(AppVisibilityPolicy("another.app", setOf(NotificationSurface.AOD))),
+ ))
+ NotificationSurface.entries.forEach { assertFalse(unknown.isBlocked(notification, it)) }
+
+ val disabled = CompiledVisibilityPolicy(VisibilityPolicy(
+ enabled = false,
+ apps = listOf(AppVisibilityPolicy(notification.packageName, NotificationSurface.entries.toSet())),
+ ))
+ NotificationSurface.entries.forEach { assertFalse(disabled.isBlocked(notification, it)) }
+ }
+
+ @Test fun `package defaults are independent by surface`() {
+ val policy = compiled(defaults = setOf(NotificationSurface.AOD, NotificationSurface.UNLOCKED_STATUSBAR))
+
+ assertTrue(policy.isBlocked(notification, NotificationSurface.AOD))
+ assertFalse(policy.isBlocked(notification, NotificationSurface.LOCKSCREEN_COLLAPSED))
+ assertTrue(policy.isBlocked(notification, NotificationSurface.UNLOCKED_STATUSBAR))
+ }
+
+ @Test fun `first matching exception replaces the package default`() {
+ val policy = compiled(
+ defaults = setOf(NotificationSurface.AOD),
+ exceptions = listOf(
+ VisibilityExceptionRule("tomorrow", setOf(NotificationSurface.LOCKSCREEN_COLLAPSED)),
+ VisibilityExceptionRule("Alice", setOf(NotificationSurface.UNLOCKED_STATUSBAR)),
+ ),
+ )
+
+ assertFalse(policy.isBlocked(notification, NotificationSurface.AOD))
+ assertTrue(policy.isBlocked(notification, NotificationSurface.LOCKSCREEN_COLLAPSED))
+ assertFalse(policy.isBlocked(notification, NotificationSurface.UNLOCKED_STATUSBAR))
+ }
+
+ @Test fun `exceptions match stable notification metadata`() {
+ listOf("example\\.messages", "\\|42\\|", "Alice", "Lunch tomorrow", "Personal", "messages")
+ .forEach { expression ->
+ val policy = compiled(exceptions = listOf(
+ VisibilityExceptionRule(expression, setOf(NotificationSurface.AOD)),
+ ))
+ assertTrue("Expected $expression to match", policy.isBlocked(notification, NotificationSurface.AOD))
+ }
+ }
+
+ @Test fun `invalid regular expressions can be rejected by an editor`() {
+ assertFalse(VisibilityExceptionRule("(", emptySet()).hasValidPattern())
+ assertTrue(VisibilityExceptionRule("valid.*", emptySet()).hasValidPattern())
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun `compilation rejects a malformed runtime policy`() {
+ compiled(exceptions = listOf(VisibilityExceptionRule("(", emptySet())))
+ }
+
+ private fun compiled(
+ defaults: Set = emptySet(),
+ exceptions: List = emptyList(),
+ ) = CompiledVisibilityPolicy(VisibilityPolicy(apps = listOf(
+ AppVisibilityPolicy(notification.packageName, defaults, exceptions),
+ )))
+}