Add guarded notification bridge

This commit is contained in:
ajp_anton
2026-08-16 17:11:15 +00:00
parent 5e0662a233
commit ae409612b4
3 changed files with 107 additions and 56 deletions
@@ -1,54 +0,0 @@
package se.ajpanton.notificationsmaster.module
import android.app.Notification
import android.os.Bundle
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
/**
* Milestone-one probe for the system-server notification path.
*
* It deliberately observes only this repository's test helper and always
* calls through. No notification is modified, cancelled, or suppressed.
*/
internal class NotificationEnqueueProbe(private val framework: XposedInterface) {
fun install(classLoader: ClassLoader) {
val managerClass = Class.forName(NOTIFICATION_MANAGER_SERVICE, false, classLoader)
val method = managerClass.declaredMethods.singleOrNull {
it.name == ENQUEUE_NOTIFICATION &&
it.returnType == Boolean::class.javaPrimitiveType &&
it.parameterTypes.any { type -> type.name.endsWith("PostNotificationTracker") }
} ?: error("No final $ENQUEUE_NOTIFICATION implementation found")
hook(method)
Log.i(TAG, "Installed fail-open notification enqueue probe")
}
private fun hook(method: Method) {
method.isAccessible = true
framework.hook(method).intercept { chain ->
logHelperNotification(chain.args)
chain.proceed()
}
}
private fun logHelperNotification(args: List<Any?>) {
val notification = args.filterIsInstance<Notification>().firstOrNull() ?: return
val packageName = args.filterIsInstance<String>().firstOrNull() ?: return
if (packageName != TEST_HELPER_PACKAGE) return
val extras: Bundle = notification.extras ?: Bundle.EMPTY
Log.i(
TAG,
"Observed helper notification before enqueue: title=${extras.getCharSequence(Notification.EXTRA_TITLE)} " +
"text=${extras.getCharSequence(Notification.EXTRA_TEXT)}",
)
}
private companion object {
const val TAG = "NotificationsMaster"
const val NOTIFICATION_MANAGER_SERVICE = "com.android.server.notification.NotificationManagerService"
const val ENQUEUE_NOTIFICATION = "enqueueNotificationInternal"
const val TEST_HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper"
}
}
@@ -11,13 +11,23 @@ import io.github.libxposed.api.XposedModuleInterface
class NotificationsMasterModule : XposedModule() {
override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
runCatching {
NotificationEnqueueProbe(this).install(param.classLoader)
SystemNotificationBridge(this, ::diagnoseHelperEvent).install(param.classLoader)
}.onFailure { error ->
Log.e(TAG, "Notification interception probe was not installed; leaving Android unchanged", error)
Log.e(TAG, "Notification bridge was not installed; leaving Android unchanged", error)
}
}
private fun diagnoseHelperEvent(event: SystemNotificationEvent) {
if (event.packageName != TEST_HELPER_PACKAGE) return
Log.i(
TAG,
"Observed helper notification: title=" + event.title + " body=" + event.body?.replace("\n", " | ") +
" channel=" + event.channelId + " summary=" + event.isGroupSummary,
)
}
private companion object {
const val TAG = "NotificationsMaster"
const val TEST_HELPER_PACKAGE = "se.ajpanton.notificationsmaster.helper"
}
}
@@ -0,0 +1,95 @@
package se.ajpanton.notificationsmaster.module
import android.app.Notification
import android.os.Build
import android.util.Log
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
import se.ajpanton.notificationsmaster.alerts.AlertEvent
import se.ajpanton.notificationsmaster.alerts.AlertSource
import se.ajpanton.notificationsmaster.capture.NotificationContents
/** Rule-relevant data copied from a system-server notification before enqueue. */
internal data class SystemNotificationEvent(
val packageName: String,
val title: String?,
val body: String?,
val channelId: String?,
val isGroupSummary: Boolean,
val isOngoing: Boolean,
) {
fun asAlertEvent(source: AlertSource = AlertSource.NOTIFICATION_POST) =
AlertEvent(packageName, source, title, body)
}
/**
* Version-specific, fail-open system-server boundary.
*
* It intentionally does not expose [Notification] itself, so later policy code
* receives a small immutable snapshot rather than a mutable framework object.
*/
internal class SystemNotificationBridge(
private val framework: XposedInterface,
private val observer: (SystemNotificationEvent) -> Unit,
) {
fun install(classLoader: ClassLoader) {
if (Build.VERSION.SDK_INT !in SUPPORTED_SDKS) {
Log.i(TAG, "Notification bridge is inactive on API " + Build.VERSION.SDK_INT)
return
}
val managerClass = Class.forName(NOTIFICATION_MANAGER_SERVICE, false, classLoader)
val method = managerClass.declaredMethods.singleOrNull(::isSupportedEnqueueMethod)
if (method == null) {
Log.w(TAG, "Notification bridge method shape was not found; leaving Android unchanged")
return
}
val notificationIndex = method.parameterTypes.indexOf(Notification::class.java)
val packageIndex = method.parameterTypes.indexOfFirst { it == String::class.java }
if (notificationIndex < 0 || packageIndex < 0 || packageIndex > notificationIndex) {
Log.w(TAG, "Notification bridge method arguments were unexpected; leaving Android unchanged")
return
}
method.isAccessible = true
framework.hook(method).intercept { chain ->
eventFrom(chain.args, packageIndex, notificationIndex)?.let(observer)
chain.proceed()
}
Log.i(TAG, "Installed API " + Build.VERSION.SDK_INT + " fail-open notification bridge")
}
private fun eventFrom(
args: List<Any?>,
packageIndex: Int,
notificationIndex: Int,
): 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
}
private companion object {
const val TAG = "NotificationsMaster"
const val NOTIFICATION_MANAGER_SERVICE = "com.android.server.notification.NotificationManagerService"
const val ENQUEUE_NOTIFICATION = "enqueueNotificationInternal"
val SUPPORTED_SDKS = 34..36
fun isSupportedEnqueueMethod(method: Method): Boolean =
method.name == ENQUEUE_NOTIFICATION &&
method.returnType == Boolean::class.javaPrimitiveType &&
method.parameterTypes.any { it.name.endsWith("PostNotificationTracker") } &&
method.parameterTypes.count { it == Notification::class.java } == 1
}
}