Suppress matched native notification alerts
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertProfile
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertRule
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||||
|
|
||||||
|
/** Installs a predictable helper policy for the AOSP LSPosed smoke test. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AlertPolicySyncDeviceTest {
|
||||||
|
@Test
|
||||||
|
fun savesMatchingHelperPolicy() {
|
||||||
|
val profile = AlertProfile("module-test", "Module test", vibrationPattern = listOf(0, 200))
|
||||||
|
val configuration = AlertConfiguration(
|
||||||
|
profiles = listOf(profile),
|
||||||
|
rules = listOf(
|
||||||
|
AlertRule(
|
||||||
|
id = "module-helper-post",
|
||||||
|
packageName = HELPER_PACKAGE,
|
||||||
|
order = 0,
|
||||||
|
sources = setOf(AlertSource.NOTIFICATION_POST),
|
||||||
|
matcher = AlertTextMatcher(),
|
||||||
|
outcome = AlertOutcome.PLAY_PROFILE,
|
||||||
|
profileId = profile.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val store = AlertConfigurationStore(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||||
|
store.save(configuration)
|
||||||
|
assertEquals(configuration, store.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY" />
|
||||||
|
|
||||||
|
<permission
|
||||||
|
android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
|
||||||
|
android:protectionLevel="signature" />
|
||||||
|
|
||||||
<!-- Required for the all-installed-apps filter; this app is not distributed through Google Play. -->
|
<!-- Required for the all-installed-apps filter; this app is not distributed through Google Play. -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -38,9 +44,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<receiver
|
||||||
android:name=".module.AlertPolicyService"
|
android:name=".module.AlertPolicyBootReceiver"
|
||||||
android:exported="true" />
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import se.ajpanton.notificationsmaster.data.AesGcmCipher
|
|||||||
import se.ajpanton.notificationsmaster.data.EncryptedPayload
|
import se.ajpanton.notificationsmaster.data.EncryptedPayload
|
||||||
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
|
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
|
||||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||||
|
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.BufferedOutputStream
|
import java.io.BufferedOutputStream
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
@@ -47,6 +48,7 @@ class AlertConfigurationStore(context: Context) {
|
|||||||
stream.flush()
|
stream.flush()
|
||||||
file.finishWrite(output)
|
file.finishWrite(output)
|
||||||
NotificationListenerComponentController.synchronize(context)
|
NotificationListenerComponentController.synchronize(context)
|
||||||
|
AlertPolicySync.publish(context, configuration)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
file.failWrite(output)
|
file.failWrite(output)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.os.Looper
|
|||||||
import android.os.VibrationAttributes
|
import android.os.VibrationAttributes
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
/** Plays resolved notification-class effects and releases all platform resources on stop. */
|
/** Plays resolved notification-class effects and releases all platform resources on stop. */
|
||||||
class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
|
class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
|
||||||
@@ -21,6 +22,7 @@ class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
|
|||||||
private var completion: (() -> Unit)? = null
|
private var completion: (() -> Unit)? = null
|
||||||
|
|
||||||
override fun play(alert: QueuedAlert, onFinished: () -> Unit) = synchronized(this) {
|
override fun play(alert: QueuedAlert, onFinished: () -> Unit) = synchronized(this) {
|
||||||
|
Log.i(TAG, "Playing custom notification alert")
|
||||||
stopLocked()
|
stopLocked()
|
||||||
val token = generation
|
val token = generation
|
||||||
completion = onFinished
|
completion = onFinished
|
||||||
@@ -83,4 +85,8 @@ class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
|
|||||||
player = null
|
player = null
|
||||||
vibrator.cancel()
|
vibrator.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "NotificationsMaster"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -12,6 +12,7 @@ import se.ajpanton.notificationsmaster.alerts.AlertPlaybackController
|
|||||||
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
|
import se.ajpanton.notificationsmaster.alerts.AlertQueueSettings
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
||||||
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
|
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
|
||||||
|
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||||
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
||||||
import se.ajpanton.notificationsmaster.model.NotificationAction
|
import se.ajpanton.notificationsmaster.model.NotificationAction
|
||||||
@@ -55,6 +56,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
|
|
||||||
override fun onListenerConnected() {
|
override fun onListenerConnected() {
|
||||||
super.onListenerConnected()
|
super.onListenerConnected()
|
||||||
|
AlertPolicySync.publishPlaybackState(this, true)
|
||||||
getActiveNotifications()?.forEach { sbn ->
|
getActiveNotifications()?.forEach { sbn ->
|
||||||
val snapshot = NotificationContents.snapshot(sbn)
|
val snapshot = NotificationContents.snapshot(sbn)
|
||||||
SeenApps.markSeen(snapshot.packageName)
|
SeenApps.markSeen(snapshot.packageName)
|
||||||
@@ -111,6 +113,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
AlertPolicySync.publishPlaybackState(this, false)
|
||||||
alertPlayback.stop()
|
alertPlayback.stop()
|
||||||
writeExecutor.shutdown()
|
writeExecutor.shutdown()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package se.ajpanton.notificationsmaster.module
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Binder
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Parcel
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertEvent
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.AlertSource
|
|
||||||
import se.ajpanton.notificationsmaster.alerts.snapshot
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A deliberately narrow endpoint for the optional system-server module.
|
|
||||||
* It is exported only so system_server can bind. A bind itself is not a
|
|
||||||
* sensitive operation; every transaction is authenticated before encrypted
|
|
||||||
* app data is read.
|
|
||||||
*/
|
|
||||||
class AlertPolicyService : Service() {
|
|
||||||
override fun onBind(intent: Intent): IBinder = PolicyBinder(applicationContext)
|
|
||||||
|
|
||||||
private class PolicyBinder(context: android.content.Context) : Binder() {
|
|
||||||
private val configurationStore = AlertConfigurationStore(context)
|
|
||||||
|
|
||||||
init { attachInterface(null, DESCRIPTOR) }
|
|
||||||
|
|
||||||
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
|
|
||||||
if (Binder.getCallingUid() != android.os.Process.SYSTEM_UID) return false
|
|
||||||
if (code == INTERFACE_TRANSACTION) {
|
|
||||||
reply?.writeString(DESCRIPTOR)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (code != TRANSACTION_EVALUATE || reply == null) return false
|
|
||||||
data.enforceInterface(DESCRIPTOR)
|
|
||||||
val packageName = data.readString() ?: return false
|
|
||||||
val source = runCatching { AlertSource.valueOf(data.readString() ?: return false) }.getOrNull() ?: return false
|
|
||||||
val event = AlertEvent(packageName, source, data.readString(), data.readString(), data.readString())
|
|
||||||
val routineUpdate = data.readBoolean()
|
|
||||||
val result = AlertPolicyEvaluator.evaluate(configurationStore.load(), event, routineUpdate)
|
|
||||||
reply.writeNoException()
|
|
||||||
reply.writeBoolean(result.silenceUnmatchedDirectAlert)
|
|
||||||
reply.writeInt(result.decisions.size)
|
|
||||||
result.decisions.forEach { decision ->
|
|
||||||
reply.writeString(decision.ruleId)
|
|
||||||
reply.writeString(decision.outcome.name)
|
|
||||||
val snapshot = decision.snapshot()
|
|
||||||
reply.writeBoolean(snapshot != null)
|
|
||||||
snapshot?.let {
|
|
||||||
reply.writeString(it.soundUri)
|
|
||||||
reply.writeLongArray(it.vibrationPattern.toLongArray())
|
|
||||||
reply.writeBoolean(it.playToCompletion)
|
|
||||||
reply.writeBoolean(it.allowDuringDnd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DESCRIPTOR = "se.ajpanton.notificationsmaster.AlertPolicy"
|
|
||||||
const val TRANSACTION_EVALUATE = IBinder.FIRST_CALL_TRANSACTION
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfiguration
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a bounded, one-way policy snapshot to the optional system-server
|
||||||
|
* module. Android enforces this APK's signature permission, and the broadcast
|
||||||
|
* is targeted at Android's system process rather than being visible to apps.
|
||||||
|
*/
|
||||||
|
internal object AlertPolicySync {
|
||||||
|
const val ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_POLICY"
|
||||||
|
const val PLAYBACK_STATE_ACTION = "se.ajpanton.notificationsmaster.UPDATE_ALERT_PLAYBACK_STATE"
|
||||||
|
const val EXTRA_CONFIGURATION = "configuration"
|
||||||
|
const val EXTRA_PLAYBACK_READY = "playback_ready"
|
||||||
|
const val PERMISSION = "se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY"
|
||||||
|
private const val SYSTEM_PACKAGE = "android"
|
||||||
|
private const val MAX_CONFIGURATION_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
fun publish(context: Context, configuration: AlertConfiguration) {
|
||||||
|
val payload = AlertConfigurationJson.encode(configuration)
|
||||||
|
if (payload.size > MAX_CONFIGURATION_BYTES) {
|
||||||
|
Log.w(TAG, "Alert policy is too large for the system cache; leaving native alerts unchanged")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context.sendBroadcast(
|
||||||
|
Intent(ACTION)
|
||||||
|
.setPackage(SYSTEM_PACKAGE)
|
||||||
|
.putExtra(EXTRA_CONFIGURATION, payload),
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Published alert policy snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publishPlaybackState(context: Context, ready: Boolean) {
|
||||||
|
context.sendBroadcast(
|
||||||
|
Intent(PLAYBACK_STATE_ACTION)
|
||||||
|
.setPackage(SYSTEM_PACKAGE)
|
||||||
|
.putExtra(EXTRA_PLAYBACK_READY, ready),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "NotificationsMaster"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-publishes active alert policy 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.util.Log
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 36's standard channel-attention boundary. It deliberately leaves the
|
||||||
|
* notification itself untouched.
|
||||||
|
*/
|
||||||
|
internal class NotificationAttentionBridge(
|
||||||
|
private val framework: XposedInterface,
|
||||||
|
private val observer: (SystemNotificationEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
fun install(classLoader: ClassLoader) {
|
||||||
|
if (Build.VERSION.SDK_INT != 36) return
|
||||||
|
val helperClass = runCatching { Class.forName(ATTENTION_HELPER, false, classLoader) }.getOrNull() ?: run {
|
||||||
|
Log.w(TAG, "Notification attention helper was not found; leaving Android unchanged")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val method = helperClass.declaredMethods.singleOrNull(::isSupportedAttention) ?: run {
|
||||||
|
Log.w(TAG, "Notification attention method shape was not found; leaving Android unchanged")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
method.isAccessible = true
|
||||||
|
framework.hook(method).intercept { chain ->
|
||||||
|
val event = eventFrom(chain.args.firstOrNull())
|
||||||
|
if (event != null && SystemAlertPolicyCache.shouldSuppress(event.asAlertEvent())) {
|
||||||
|
observer(event)
|
||||||
|
Log.i(TAG, "Suppressed native notification effects for matched alert from " + event.packageName)
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Installed API 36 notification attention bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eventFrom(record: Any?): SystemNotificationEvent? = runCatching {
|
||||||
|
val sbn = record?.javaClass?.getMethod("getSbn")?.invoke(record) as? StatusBarNotification ?: return null
|
||||||
|
SystemNotificationEvent.fromStatusBarNotification(sbn)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "NotificationsMaster"
|
||||||
|
const val ATTENTION_HELPER = "com.android.server.notification.NotificationAttentionHelper"
|
||||||
|
|
||||||
|
fun isSupportedAttention(method: Method): Boolean =
|
||||||
|
method.name == "buzzBeepBlinkLocked" &&
|
||||||
|
method.returnType == Int::class.javaPrimitiveType &&
|
||||||
|
method.parameterTypes.size == 2 &&
|
||||||
|
method.parameterTypes[0].name == "com.android.server.notification.NotificationRecord" &&
|
||||||
|
method.parameterTypes[1].name.endsWith("NotificationAttentionHelper\$Signals")
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-2
@@ -11,6 +11,8 @@ import io.github.libxposed.api.XposedModuleInterface
|
|||||||
class NotificationsMasterModule : XposedModule() {
|
class NotificationsMasterModule : XposedModule() {
|
||||||
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
|
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
SystemAlertPolicyCache.installWhenReady()
|
||||||
|
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
|
||||||
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
|
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
Log.e(TAG, "Notification bridge was not installed; leaving Android unchanged", error)
|
Log.e(TAG, "Notification bridge was not installed; leaving Android unchanged", error)
|
||||||
@@ -18,11 +20,20 @@ class NotificationsMasterModule : XposedModule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun diagnoseHelperEvent(event: SystemNotificationEvent) {
|
private fun diagnoseHelperEvent(event: SystemNotificationEvent) {
|
||||||
|
diagnoseHelperEvent("enqueue", event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun diagnoseAttentionHelperEvent(event: SystemNotificationEvent) {
|
||||||
|
diagnoseHelperEvent("attention", event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun diagnoseHelperEvent(phase: String, event: SystemNotificationEvent) {
|
||||||
if (event.packageName != TEST_HELPER_PACKAGE) return
|
if (event.packageName != TEST_HELPER_PACKAGE) return
|
||||||
|
val matches = SystemAlertPolicyCache.evaluate(event.asAlertEvent()).decisions.size
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"Observed helper notification: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
|
"Observed helper $phase: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
|
||||||
" channel=" + event.channelId + " summary=" + event.isGroupSummary,
|
" channel=" + event.channelId + " summary=" + event.isGroupSummary + " matches=" + matches,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package se.ajpanton.notificationsmaster.module
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
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.alerts.AlertConfiguration
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationJson
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertEvaluation
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertPolicyEvaluator
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertEvent
|
||||||
|
import se.ajpanton.notificationsmaster.alerts.AlertOutcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A system-server-local, read-only copy of the app's alert rules.
|
||||||
|
*
|
||||||
|
* Notification hooks only read this volatile snapshot; they never synchronously
|
||||||
|
* enter the app process. Missing, rejected, or malformed policy always means
|
||||||
|
* no suppression.
|
||||||
|
*/
|
||||||
|
internal object SystemAlertPolicyCache {
|
||||||
|
@Volatile
|
||||||
|
private var configuration = AlertConfiguration(enabled = false)
|
||||||
|
@Volatile
|
||||||
|
private var playbackReady = false
|
||||||
|
private var installed = false
|
||||||
|
private var attempts = 0
|
||||||
|
|
||||||
|
fun installWhenReady() {
|
||||||
|
Handler(Looper.getMainLooper()).post(::tryInstall)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryInstall() {
|
||||||
|
if (installed) return
|
||||||
|
val context = systemContext()
|
||||||
|
if (context == null) {
|
||||||
|
retryOrGiveUp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
context.registerReceiver(
|
||||||
|
PolicyReceiver(),
|
||||||
|
IntentFilter().apply {
|
||||||
|
addAction(AlertPolicySync.ACTION)
|
||||||
|
addAction(AlertPolicySync.PLAYBACK_STATE_ACTION)
|
||||||
|
},
|
||||||
|
AlertPolicySync.PERMISSION,
|
||||||
|
null,
|
||||||
|
Context.RECEIVER_EXPORTED,
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
installed = true
|
||||||
|
Log.i(TAG, "Installed fail-closed alert policy cache for " + context.packageName)
|
||||||
|
}.onFailure {
|
||||||
|
retryOrGiveUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun evaluate(event: AlertEvent): AlertEvaluation =
|
||||||
|
AlertPolicyEvaluator.evaluate(configuration, event, isRoutineUpdate = false)
|
||||||
|
|
||||||
|
fun shouldSuppress(event: AlertEvent): Boolean = playbackReady &&
|
||||||
|
evaluate(event).decisions.any { it.outcome != AlertOutcome.PASS_THROUGH }
|
||||||
|
|
||||||
|
private fun systemContext(): Context? = runCatching {
|
||||||
|
val activityThread = Class.forName("android.app.ActivityThread")
|
||||||
|
val current = activityThread.getMethod("currentActivityThread").invoke(null) ?: return null
|
||||||
|
activityThread.getMethod("getSystemContext").invoke(current) as? Context
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun retryOrGiveUp() {
|
||||||
|
attempts += 1
|
||||||
|
if (attempts < MAX_ATTEMPTS) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed(::tryInstall, RETRY_DELAY_MILLIS)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "System policy cache is unavailable; leaving native alerts unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PolicyReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == AlertPolicySync.PLAYBACK_STATE_ACTION) {
|
||||||
|
playbackReady = intent.getBooleanExtra(AlertPolicySync.EXTRA_PLAYBACK_READY, false)
|
||||||
|
Log.i(TAG, "Alert playback listener ready=$playbackReady")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val payload = intent.getByteArrayExtra(AlertPolicySync.EXTRA_CONFIGURATION) ?: return
|
||||||
|
if (payload.size > MAX_CONFIGURATION_BYTES) return
|
||||||
|
configuration = runCatching { AlertConfigurationJson.decode(payload) }
|
||||||
|
.onFailure { Log.w(TAG, "Rejected malformed alert policy snapshot", it) }
|
||||||
|
.getOrNull() ?: return
|
||||||
|
Log.i(TAG, "Updated fail-closed alert policy cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "NotificationsMaster"
|
||||||
|
private const val MAX_CONFIGURATION_BYTES = 64 * 1024
|
||||||
|
private const val MAX_ATTEMPTS = 10
|
||||||
|
private const val RETRY_DELAY_MILLIS = 1_000L
|
||||||
|
}
|
||||||
+22
-15
@@ -2,6 +2,7 @@ package se.ajpanton.notificationsmaster.module
|
|||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import io.github.libxposed.api.XposedInterface
|
import io.github.libxposed.api.XposedInterface
|
||||||
import java.lang.reflect.Method
|
import java.lang.reflect.Method
|
||||||
@@ -14,12 +15,31 @@ internal data class SystemNotificationEvent(
|
|||||||
val packageName: String,
|
val packageName: String,
|
||||||
val title: String?,
|
val title: String?,
|
||||||
val body: String?,
|
val body: String?,
|
||||||
|
val sender: String?,
|
||||||
val channelId: String?,
|
val channelId: String?,
|
||||||
val isGroupSummary: Boolean,
|
val isGroupSummary: Boolean,
|
||||||
val isOngoing: Boolean,
|
val isOngoing: Boolean,
|
||||||
) {
|
) {
|
||||||
fun asAlertEvent(source: AlertSource = AlertSource.NOTIFICATION_POST) =
|
fun asAlertEvent(source: AlertSource = AlertSource.NOTIFICATION_POST) =
|
||||||
AlertEvent(packageName, source, title, body)
|
AlertEvent(packageName, source, title, body, sender)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromNotification(packageName: String, notification: Notification): SystemNotificationEvent {
|
||||||
|
val contents = NotificationContents.alertContents(notification)
|
||||||
|
return SystemNotificationEvent(
|
||||||
|
packageName = packageName,
|
||||||
|
title = contents.title,
|
||||||
|
body = contents.body,
|
||||||
|
sender = contents.sender,
|
||||||
|
channelId = notification.channelId,
|
||||||
|
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
|
||||||
|
isOngoing = notification.flags and Notification.FLAG_ONGOING_EVENT != 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromStatusBarNotification(sbn: StatusBarNotification): SystemNotificationEvent =
|
||||||
|
fromNotification(sbn.packageName, sbn.notification)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,20 +84,7 @@ internal class SystemNotificationBridge(
|
|||||||
): SystemNotificationEvent? {
|
): SystemNotificationEvent? {
|
||||||
val packageName = args.getOrNull(packageIndex) as? String ?: return null
|
val packageName = args.getOrNull(packageIndex) as? String ?: return null
|
||||||
val notification = args.getOrNull(notificationIndex) as? Notification ?: return null
|
val notification = args.getOrNull(notificationIndex) as? Notification ?: return null
|
||||||
val title = notification.extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
return SystemNotificationEvent.fromNotification(packageName, notification)
|
||||||
return SystemNotificationEvent(
|
|
||||||
packageName = packageName,
|
|
||||||
title = title,
|
|
||||||
body = notificationBody(notification, title),
|
|
||||||
channelId = notification.channelId,
|
|
||||||
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
|
|
||||||
isOngoing = notification.flags and Notification.FLAG_ONGOING_EVENT != 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notificationBody(notification: Notification, title: String?): String? {
|
|
||||||
val contents = NotificationContents.extract(notification) ?: return null
|
|
||||||
return title?.let { contents.removePrefix(it + "\n") }?.ifBlank { null } ?: contents
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
+10
@@ -7,6 +7,7 @@ import android.service.notification.NotificationListenerService
|
|||||||
import android.util.Log
|
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification listeners are re-bound by Android after boot when their component
|
* Notification listeners are re-bound by Android after boot when their component
|
||||||
@@ -42,6 +43,15 @@ internal object NotificationListenerComponentController {
|
|||||||
if (packageManager.getComponentEnabledSetting(component) != desired) {
|
if (packageManager.getComponentEnabledSetting(component) != desired) {
|
||||||
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
|
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
|
||||||
}
|
}
|
||||||
|
val bootReceiver = ComponentName(applicationContext, AlertPolicyBootReceiver::class.java)
|
||||||
|
val bootReceiverState = if (hasEnabledAlertRule) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
if (packageManager.getComponentEnabledSetting(bootReceiver) != bootReceiverState) {
|
||||||
|
packageManager.setComponentEnabledSetting(bootReceiver, bootReceiverState, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
try {
|
try {
|
||||||
// Data clearing and OEM listener management can leave an enabled
|
// Data clearing and OEM listener management can leave an enabled
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.app.NotificationManager
|
|||||||
import android.app.Person
|
import android.app.Person
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.RingtoneManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
@@ -21,7 +23,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(state: Bundle?) {
|
override fun onCreate(state: Bundle?) {
|
||||||
super.onCreate(state)
|
super.onCreate(state)
|
||||||
manager.createNotificationChannel(NotificationChannel(CHANNEL, "Test", NotificationManager.IMPORTANCE_DEFAULT))
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, "Test", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
setSound(
|
||||||
|
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
|
||||||
|
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build(),
|
||||||
|
)
|
||||||
|
enableVibration(true)
|
||||||
|
vibrationPattern = longArrayOf(0, 200)
|
||||||
|
},
|
||||||
|
)
|
||||||
setContentView(ScrollView(this).apply {
|
setContentView(ScrollView(this).apply {
|
||||||
addView(LinearLayout(this@MainActivity).apply {
|
addView(LinearLayout(this@MainActivity).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
|
|||||||
Reference in New Issue
Block a user