Add notification visibility policy bridge
This commit is contained in:
+38
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,10 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".module.VisibilityPolicyRequestReceiver"
|
||||||
|
android:exported="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package se.ajpanton.notificationsmaster
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
|
import se.ajpanton.notificationsmaster.module.VisibilityPolicySync
|
||||||
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
|
import se.ajpanton.notificationsmaster.settings.AppFilterSettingsStore
|
||||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||||
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
|
import se.ajpanton.notificationsmaster.settings.PerAppEventSettingsStore
|
||||||
@@ -11,6 +12,7 @@ class NotificationLogApplication : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
synchronizeListenerComponent()
|
synchronizeListenerComponent()
|
||||||
|
VisibilityPolicySync.publishStored(this)
|
||||||
Thread(::removeStaleAppStorage, "notification-log-storage-cleanup").start()
|
Thread(::removeStaleAppStorage, "notification-log-storage-cleanup").start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ internal object AlertPolicySync {
|
|||||||
private const val TAG = "NotificationsMaster"
|
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() {
|
class AlertPolicyBootReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
|
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
|
||||||
AlertPolicySync.publish(context, se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore(context).load())
|
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.
|
* in a compatible framework and grants it a system-server scope.
|
||||||
*/
|
*/
|
||||||
class NotificationsMasterModule : XposedModule() {
|
class NotificationsMasterModule : XposedModule() {
|
||||||
|
override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) {
|
||||||
|
if (param.packageName in VisibilityPolicySync.TARGET_PACKAGES) {
|
||||||
|
ProcessVisibilityPolicyCache.installWhenReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
|
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
|
||||||
SystemAlertPolicyCache.installWhenReady()
|
SystemAlertPolicyCache.installWhenReady()
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
|||||||
+104
@@ -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" }
|
||||||
|
}
|
||||||
+10
-2
@@ -8,6 +8,7 @@ import android.util.Log
|
|||||||
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||||
import se.ajpanton.notificationsmaster.module.AlertPolicyBootReceiver
|
import se.ajpanton.notificationsmaster.module.AlertPolicyBootReceiver
|
||||||
|
import se.ajpanton.notificationsmaster.visibility.VisibilityPolicyStore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification listeners are re-bound by Android after boot when their component
|
* Notification listeners are re-bound by Android after boot when their component
|
||||||
@@ -23,6 +24,7 @@ internal object NotificationListenerComponentController {
|
|||||||
LoggingType.entries.associateWith(rules::ruleFor),
|
LoggingType.entries.associateWith(rules::ruleFor),
|
||||||
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
|
PerAppEventSettingsStore(applicationContext).hasEnabledEventOverride(),
|
||||||
AlertConfigurationStore(applicationContext).load().needsListener,
|
AlertConfigurationStore(applicationContext).load().needsListener,
|
||||||
|
VisibilityPolicyStore(applicationContext).load().hasRules,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +33,17 @@ internal object NotificationListenerComponentController {
|
|||||||
rules: Map<LoggingType, LoggingRule>,
|
rules: Map<LoggingType, LoggingRule>,
|
||||||
hasEnabledEventOverride: Boolean = false,
|
hasEnabledEventOverride: Boolean = false,
|
||||||
hasEnabledAlertRule: Boolean = false,
|
hasEnabledAlertRule: Boolean = false,
|
||||||
|
hasVisibilityRules: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val applicationContext = context.applicationContext
|
val applicationContext = context.applicationContext
|
||||||
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
|
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
|
||||||
val packageManager = applicationContext.packageManager
|
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
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
} else {
|
} else {
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
@@ -44,7 +52,7 @@ internal object NotificationListenerComponentController {
|
|||||||
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
|
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
|
||||||
}
|
}
|
||||||
val bootReceiver = ComponentName(applicationContext, AlertPolicyBootReceiver::class.java)
|
val bootReceiver = ComponentName(applicationContext, AlertPolicyBootReceiver::class.java)
|
||||||
val bootReceiverState = if (hasEnabledAlertRule) {
|
val bootReceiverState = if (hasEnabledAlertRule || hasVisibilityRules) {
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
} else {
|
} else {
|
||||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,9 @@ object NotificationListenerPolicy {
|
|||||||
rules: Map<LoggingType, LoggingRule>,
|
rules: Map<LoggingType, LoggingRule>,
|
||||||
hasEnabledEventOverride: Boolean = false,
|
hasEnabledEventOverride: Boolean = false,
|
||||||
hasEnabledAlertRule: Boolean = false,
|
hasEnabledAlertRule: Boolean = false,
|
||||||
|
hasVisibilityRules: Boolean = false,
|
||||||
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
|
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } ||
|
||||||
hasEnabledEventOverride ||
|
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
|
android
|
||||||
system
|
system
|
||||||
|
com.android.systemui
|
||||||
|
com.samsung.android.app.aodservice
|
||||||
|
|||||||
+4
@@ -31,6 +31,10 @@ class NotificationListenerPolicyTest {
|
|||||||
assertTrue(NotificationListenerPolicy.shouldRun(rules(), hasEnabledAlertRule = true))
|
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, Boolean>) = LoggingType.entries.associateWith { type ->
|
private fun rules(vararg enabled: Pair<LoggingType, Boolean>) = LoggingType.entries.associateWith { type ->
|
||||||
LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false)
|
LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false)
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NotificationSurface> = emptySet(),
|
||||||
|
exceptions: List<VisibilityExceptionRule> = emptyList(),
|
||||||
|
) = CompiledVisibilityPolicy(VisibilityPolicy(apps = listOf(
|
||||||
|
AppVisibilityPolicy(notification.packageName, defaults, exceptions),
|
||||||
|
)))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user