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">
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<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. -->
|
||||
<uses-permission
|
||||
@@ -38,9 +44,14 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".module.AlertPolicyService"
|
||||
android:exported="true" />
|
||||
<receiver
|
||||
android:name=".module.AlertPolicyBootReceiver"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -8,6 +8,7 @@ import se.ajpanton.notificationsmaster.data.AesGcmCipher
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedPayload
|
||||
import se.ajpanton.notificationsmaster.data.LogEncryptionKeyProvider
|
||||
import se.ajpanton.notificationsmaster.settings.NotificationListenerComponentController
|
||||
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.DataInputStream
|
||||
@@ -47,6 +48,7 @@ class AlertConfigurationStore(context: Context) {
|
||||
stream.flush()
|
||||
file.finishWrite(output)
|
||||
NotificationListenerComponentController.synchronize(context)
|
||||
AlertPolicySync.publish(context, configuration)
|
||||
} catch (error: Exception) {
|
||||
file.failWrite(output)
|
||||
throw error
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.os.Looper
|
||||
import android.os.VibrationAttributes
|
||||
import android.os.VibrationEffect
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
|
||||
/** Plays resolved notification-class effects and releases all platform resources on stop. */
|
||||
class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
|
||||
@@ -21,6 +22,7 @@ class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
|
||||
private var completion: (() -> Unit)? = null
|
||||
|
||||
override fun play(alert: QueuedAlert, onFinished: () -> Unit) = synchronized(this) {
|
||||
Log.i(TAG, "Playing custom notification alert")
|
||||
stopLocked()
|
||||
val token = generation
|
||||
completion = onFinished
|
||||
@@ -83,4 +85,8 @@ class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
|
||||
player = null
|
||||
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.AlertSource
|
||||
import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
|
||||
import se.ajpanton.notificationsmaster.module.AlertPolicySync
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
|
||||
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
|
||||
import se.ajpanton.notificationsmaster.model.NotificationAction
|
||||
@@ -55,6 +56,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
AlertPolicySync.publishPlaybackState(this, true)
|
||||
getActiveNotifications()?.forEach { sbn ->
|
||||
val snapshot = NotificationContents.snapshot(sbn)
|
||||
SeenApps.markSeen(snapshot.packageName)
|
||||
@@ -111,6 +113,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
AlertPolicySync.publishPlaybackState(this, false)
|
||||
alertPlayback.stop()
|
||||
writeExecutor.shutdown()
|
||||
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() {
|
||||
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
|
||||
runCatching {
|
||||
SystemAlertPolicyCache.installWhenReady()
|
||||
NotificationAttentionBridge(this, ::diagnoseAttentionHelperEvent).install(param.classLoader)
|
||||
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
|
||||
}.onFailure { 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) {
|
||||
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
|
||||
val matches = SystemAlertPolicyCache.evaluate(event.asAlertEvent()).decisions.size
|
||||
Log.i(
|
||||
TAG,
|
||||
"Observed helper notification: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
|
||||
" channel=" + event.channelId + " summary=" + event.isGroupSummary,
|
||||
"Observed helper $phase: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
|
||||
" 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.os.Build
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import java.lang.reflect.Method
|
||||
@@ -14,12 +15,31 @@ internal data class SystemNotificationEvent(
|
||||
val packageName: String,
|
||||
val title: String?,
|
||||
val body: String?,
|
||||
val sender: String?,
|
||||
val channelId: String?,
|
||||
val isGroupSummary: Boolean,
|
||||
val isOngoing: Boolean,
|
||||
) {
|
||||
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? {
|
||||
val packageName = args.getOrNull(packageIndex) as? String ?: return null
|
||||
val notification = args.getOrNull(notificationIndex) as? Notification ?: return null
|
||||
val title = notification.extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||
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
|
||||
return SystemNotificationEvent.fromNotification(packageName, notification)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
+10
@@ -7,6 +7,7 @@ import android.service.notification.NotificationListenerService
|
||||
import android.util.Log
|
||||
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
|
||||
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
|
||||
import se.ajpanton.notificationsmaster.module.AlertPolicyBootReceiver
|
||||
|
||||
/**
|
||||
* Notification listeners are re-bound by Android after boot when their component
|
||||
@@ -42,6 +43,15 @@ internal object NotificationListenerComponentController {
|
||||
if (packageManager.getComponentEnabledSetting(component) != desired) {
|
||||
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) {
|
||||
try {
|
||||
// Data clearing and OEM listener management can leave an enabled
|
||||
|
||||
Reference in New Issue
Block a user