From a3063d0c9a6463798de8cb8b7a7f787d7ee3da8b Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sat, 29 Aug 2026 12:19:54 +0000 Subject: [PATCH] Add notification visibility policy bridge --- .../visibility/VisibilityPolicyStoreTest.kt | 38 +++++++ app/src/main/AndroidManifest.xml | 4 + .../NotificationLogApplication.kt | 2 + .../module/AlertPolicySync.kt | 3 +- .../module/NotificationsMasterModule.kt | 6 + .../module/ProcessVisibilityPolicyCache.kt | 104 ++++++++++++++++++ .../module/VisibilityPolicySync.kt | 63 +++++++++++ ...NotificationListenerComponentController.kt | 12 +- .../settings/NotificationListenerPolicy.kt | 4 +- .../visibility/VisibilityPolicy.kt | 90 +++++++++++++++ .../visibility/VisibilityPolicyJson.kt | 50 +++++++++ .../visibility/VisibilityPolicyStore.kt | 65 +++++++++++ .../main/resources/META-INF/xposed/scope.list | 2 + .../NotificationListenerPolicyTest.kt | 4 + .../visibility/VisibilityPolicyJsonTest.kt | 34 ++++++ .../visibility/VisibilityPolicyTest.kt | 80 ++++++++++++++ 16 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStoreTest.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/ProcessVisibilityPolicyCache.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/VisibilityPolicySync.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicy.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJson.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyStore.kt create mode 100644 app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyJsonTest.kt create mode 100644 app/src/test/java/se/ajpanton/notificationsmaster/visibility/VisibilityPolicyTest.kt 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), + ))) +}