Add guarded notification bridge
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user