diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f19c4e..a00114a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,5 +92,8 @@ dependencies { implementation(libs.swiperefreshlayout) implementation(libs.lifecycle.runtime.ktx) + // Present only when the user enables this APK as an LSPosed module. + compileOnly(libs.libxposed.api) + testImplementation(libs.junit) } diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationEnqueueProbe.kt b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationEnqueueProbe.kt new file mode 100644 index 0000000..dda0831 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationEnqueueProbe.kt @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..e1a2bc2 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/module/NotificationsMasterModule.kt @@ -0,0 +1,23 @@ +package se.ajpanton.notificationsmaster.module + +import android.util.Log +import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface + +/** + * Optional libxposed entry point. It is inert unless the user enables this APK + * in a compatible framework and grants it a system-server scope. + */ +class NotificationsMasterModule : XposedModule() { + override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) { + runCatching { + NotificationEnqueueProbe(this).install(param.classLoader) + }.onFailure { error -> + Log.e(TAG, "Notification interception probe was not installed; leaving Android unchanged", error) + } + } + + private companion object { + const val TAG = "NotificationsMaster" + } +} diff --git a/app/src/main/resources/META-INF/xposed/java_init.list b/app/src/main/resources/META-INF/xposed/java_init.list new file mode 100644 index 0000000..67447e1 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/java_init.list @@ -0,0 +1 @@ +se.ajpanton.notificationsmaster.module.NotificationsMasterModule diff --git a/app/src/main/resources/META-INF/xposed/module.prop b/app/src/main/resources/META-INF/xposed/module.prop new file mode 100644 index 0000000..c3975fe --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/module.prop @@ -0,0 +1,3 @@ +minApiVersion=101 +targetApiVersion=101 +staticScope=true diff --git a/app/src/main/resources/META-INF/xposed/scope.list b/app/src/main/resources/META-INF/xposed/scope.list new file mode 100644 index 0000000..b079f01 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/scope.list @@ -0,0 +1,2 @@ +android +system diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eac4c32..7b519e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ recyclerView = "1.3.2" swipeRefreshLayout = "1.1.0" lifecycle = "2.8.7" junit = "4.13.2" +libxposed = "101.0.1" [libraries] appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } @@ -21,6 +22,7 @@ recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } junit = { group = "junit", name = "junit", version.ref = "junit" } +libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "libxposed" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }