From ae409612b4b03d180fe390c7a2b6eff12d872b0b Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 16 Aug 2026 17:11:15 +0000 Subject: [PATCH] Add guarded notification bridge --- .../module/NotificationEnqueueProbe.kt | 54 ----------- .../module/NotificationsMasterModule.kt | 14 ++- .../module/SystemNotificationBridge.kt | 95 +++++++++++++++++++ 3 files changed, 107 insertions(+), 56 deletions(-) delete mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationEnqueueProbe.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationEnqueueProbe.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationEnqueueProbe.kt deleted file mode 100644 index dda0831..0000000 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationEnqueueProbe.kt +++ /dev/null @@ -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) { - val notification = args.filterIsInstance().firstOrNull() ?: return - val packageName = args.filterIsInstance().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" - } -} diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt index e1a2bc2..5d62aab 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -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" } } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt new file mode 100644 index 0000000..90c76c5 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/SystemNotificationBridge.kt @@ -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, + 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 + } +}