From 06ed8e7037eb5d0abc1c4bf114b260494f942bf7 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 06:36:34 +0200 Subject: [PATCH 01/25] Keep the interceptor chain consistent after a protective recovery ExceptionMode.PROTECTIVE is documented as: "If the exception is thrown before proceed(), the framework will continue the chain without the hook; if the exception is thrown after proceed, the framework will return the value / exception proceeded as the result." executeDownstream recorded a node's own hooker exception in downstreamThrowable but never cleared it when handleInterceptorException went on to recover, so the node advertised both a stale exception and a null result. A parent node reads exactly those two fields, so it would rethrow an exception the chain had already suppressed. Two DEFAULT-mode hooks are enough to hit it, with no concurrency: the lower-priority hook throws before proceed and is skipped, the chain produces the original value, then the higher-priority hook throws after proceed and the parent resurrects the first hook's exception. The caller sees IllegalStateException instead of the method's return value. Route the recovery through executeDownstream as well, and have it write both fields so exactly one is ever meaningful. Also return an immutable list from Chain.getArgs(), which the API documents as "The returned list is immutable" but toList() implements with a mutable ArrayList. --- .../org/matrix/vector/impl/hooks/VectorChain.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt index 4a423c108..9dc087bf9 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt @@ -4,6 +4,7 @@ import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedInterface.Chain import io.github.libxposed.api.XposedInterface.ExceptionMode import java.lang.reflect.Executable +import java.util.Collections import org.lsposed.lspd.util.Utils /** Represents a registered hook configuration, stored natively by [HookBridge]. */ @@ -38,7 +39,8 @@ class VectorChain( override fun getThisObject(): Any? = thisObj - override fun getArgs(): List = args.toList() + // The API documents this list as immutable; toList() hands back a mutable ArrayList. + override fun getArgs(): List = Collections.unmodifiableList(args.asList()) override fun getArg(index: Int): Any? = args[index] @@ -66,20 +68,31 @@ class VectorChain( return try { executeDownstream { record.hooker.intercept(nextChain) } } catch (t: Throwable) { - handleInterceptorException(t, record, nextChain, thisObject, currentArgs) + // Recording the recovery keeps this node's cached state consistent: once the hooker's + // exception has been suppressed, parent nodes must observe the recovered outcome and + // not the exception we just swallowed. + executeDownstream { + handleInterceptorException(t, record, nextChain, thisObject, currentArgs) + } } } /** * Executes the block and caches the downstream state so parent chains can recover it if the * current interceptor crashes during post-processing. + * + * Exactly one of [downstreamResult] and [downstreamThrowable] is meaningful after this returns, + * so both are always written; leaving a stale value behind would let a parent node resurrect an + * exception this node already handled. */ private inline fun executeDownstream(block: () -> Any?): Any? { return try { val result = block() downstreamResult = result + downstreamThrowable = null result } catch (t: Throwable) { + downstreamResult = null downstreamThrowable = t throw t } From 98f263721ef99c6c24a318426c005600946e11e0 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 06:36:47 +0200 Subject: [PATCH 02/25] Do not fail a hook or an invoker on an executable with no hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated symptoms, one shared cause plus one missing guard. callbackSnapshot returns nullptr when the executable is absent from hooked_methods, but the Kotlin declaration was non-null, so Kotlin's own null check threw. Invokers default to Type.Chain.FULL, so the ordinary act of obtaining an invoker for a method the module has not hooked and calling it raised NullPointerException — a type invoke() does not document, next to InvocationTargetException, IllegalArgumentException and IllegalAccessException. Declare the return nullable and treat null as "no hooks" on both call sites. Separately, intercept() documents "@throws IllegalArgumentException if origin is framework internal or Constructor#newInstance". Method.invoke was rejected but Constructor.newInstance was accepted, even though the framework reflects through both and hooking either recurses into hook dispatch. --- .../matrix/vector/impl/hooks/BaseInvoker.kt | 21 ++++++++++++------- .../vector/impl/hooks/VectorNativeHooker.kt | 16 ++++++++++++-- .../matrix/vector/nativebridge/HookBridge.kt | 6 +++++- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt index c800fd89a..76db9a775 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt @@ -28,16 +28,14 @@ internal abstract class BaseInvoker, U : Executable>( /** Resolves the current [type] and executes the underlying method. */ protected fun proceedInvocation(thisObject: Any?, args: Array): Any? { return when (val currentType = type) { - is Invoker.Type.Origin -> { - try { - HookBridge.invokeOriginalMethod(executable, thisObject, *args) - } catch (e: InvocationTargetException) { - throw e.cause ?: e - } - } + is Invoker.Type.Origin -> invokeOriginal(thisObject, args) is Invoker.Type.Chain -> { val snapshots = HookBridge.callbackSnapshot(VectorHookRecord::class.java, executable) + // The executable carries no hooks, so there is no chain to enter. Invokers + // default to Type.Chain.FULL, so this is the ordinary case for a module + // that obtains an invoker for a method it has not hooked. + ?: return invokeOriginal(thisObject, args) @Suppress("UNCHECKED_CAST") val allModernHooks = snapshots[0] as Array @@ -65,6 +63,15 @@ internal abstract class BaseInvoker, U : Executable>( } } + /** Invokes the original executable, unwrapping the reflective wrapper exception. */ + private fun invokeOriginal(thisObject: Any?, args: Array): Any? { + return try { + HookBridge.invokeOriginalMethod(executable, thisObject, *args) + } catch (e: InvocationTargetException) { + throw e.cause ?: e + } + } + /** Helper to generate the JNI shorty for non-virtual special invocations. */ protected fun getExecutableShorty(): CharArray { val parameterTypes = executable.parameterTypes diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt index cbfdb7506..f55f719db 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt @@ -6,6 +6,7 @@ import io.github.libxposed.api.XposedInterface.HookBuilder import io.github.libxposed.api.XposedInterface.HookHandle import io.github.libxposed.api.XposedInterface.Hooker import io.github.libxposed.api.error.HookFailedError +import java.lang.reflect.Constructor import java.lang.reflect.Executable import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method @@ -37,6 +38,14 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder { origin.name == "invoke" ) { throw IllegalArgumentException("Cannot hook Method.invoke") + } else if ( + origin is Method && + origin.declaringClass == Constructor::class.java && + origin.name == "newInstance" + ) { + // Named alongside Method.invoke by the API: the framework reflects through both, so + // hooking either recurses into the hook dispatch. + throw IllegalArgumentException("Cannot hook Constructor.newInstance") } val record = VectorHookRecord(hooker, priority, exceptionMode) @@ -72,8 +81,11 @@ class VectorNativeHooker(private val method: T) { val thisObject = if (isStatic) null else args[0] val actualArgs = if (isStatic) args else args.sliceArray(1 until args.size) - // Retrieve the hook snapshots - val snapshots = HookBridge.callbackSnapshot(VectorHookRecord::class.java, method) + // Retrieve the hook snapshots. Null means every hook was removed after this trampoline was + // entered, which is indistinguishable from having none. + val snapshots = + HookBridge.callbackSnapshot(VectorHookRecord::class.java, method) + ?: return invokeOriginalSafely(thisObject, actualArgs) @Suppress("UNCHECKED_CAST") val modernHooks = snapshots[0] as Array val legacyHooks = snapshots[1] diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index ea2a5bfd6..08eb5c421 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -54,8 +54,12 @@ object HookBridge { @JvmStatic @FastNative external fun setTrusted(cookie: Any?): Boolean + /** Returns null when [method] carries no hooks at all. */ @JvmStatic - external fun callbackSnapshot(hooker_callback: Class<*>, method: Executable): Array> + external fun callbackSnapshot( + hooker_callback: Class<*>, + method: Executable, + ): Array>? @JvmStatic external fun getStaticInitializer(clazz: Class<*>): Method? } From 80eb1e27755e398e1197d678eb4918ffed8486e8 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 06:37:06 +0200 Subject: [PATCH 03/25] Parse module.prop as the Java Properties file it is specified to be package-info.java pins the format: "META-INF/xposed/module.prop (Java java.util.Properties format)". The daemon instead split each line on the first '=', which rejects ':' as a separator, treats '!' comment lines as data, and handles neither escapes nor line continuations. A spec-legal module.prop could therefore yield no targetApiVersion, which sends the module down the "NONE" branch and silently makes it unloadable. The manager app already reads the same file with Properties.load, so the two sides could disagree about the same APK. Use Properties on the daemon side too, and match the manager's leading-digit integer parsing so a value like "101.0" cannot be read differently by each. Properties.load rejects malformed \uXXXX escapes where the old parser tolerated them, which would have turned one silent-drop failure mode into another, so the parse is isolated and falls back to empty properties. A legacy module in particular ships no module.prop at all and is selected by assets/xposed_init. --- .../matrix/vector/daemon/data/FileSystem.kt | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index 5eeb7a1b9..2a555bcfc 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -27,6 +27,7 @@ import java.nio.file.attribute.PosixFilePermissions import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream @@ -160,6 +161,10 @@ object FileSystem { return memory } + /** Reads the leading integer of a module.prop value, as the manager's extractIntPart does. */ + private fun leadingInt(value: String?): Int = + value?.trim()?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 + /** Parses the module APK, extracts init lists, and loads DEXes into SharedMemory. */ fun loadModule(apkPath: String, obfuscate: Boolean): PreLoadedApk? { val file = File(apkPath) @@ -173,20 +178,24 @@ object FileSystem { runCatching { ZipFile(file).use { zip -> - // Parse module.prop to get targetApiVersion + // module.prop is specified as Java Properties format. Parsing it by hand mishandles + // ':' as a separator, '!' comments, escapes and line continuations, and the manager + // app already reads the same file with Properties.load. val props = - zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> - zip.getInputStream(entry).bufferedReader().useLines { lines -> - lines - .filter { it.contains("=") } - .associate { - val parts = it.split("=", limit = 2) - parts[0].trim() to parts[1].trim() - } + Properties().apply { + zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> + // Properties.load rejects a malformed \uXXXX escape, which the old hand-rolled + // parser tolerated. Keep that tolerance: a bad module.prop must not make the + // APK unloadable, not least because a legacy module is selected by + // assets/xposed_init and needs no module.prop at all. + runCatching { zip.getInputStream(entry).use { load(it) } } + .onFailure { Log.w(TAG, "Malformed module.prop in $apkPath", it) } } - } ?: emptyMap() + } - val targetApi = props["targetApiVersion"]?.toIntOrNull() ?: 0 + // Leading-digit parsing, matching ModuleUtil.extractIntPart in the manager, so the two + // sides cannot disagree about a value like "101.0". + val targetApi = leadingInt(props.getProperty("targetApiVersion")) val hasLegacyFile = zip.getEntry("assets/xposed_init") != null // Determine Loading Strategy based on Priority: API 101+ > Legacy > API 100 From 889e42f00376183431d93e244dc4d4ea4416aef3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 06:37:06 +0200 Subject: [PATCH 04/25] Stop one malformed module from emptying the manager's module list minApiVersion and targetApiVersion are required of a module, but the framework has to survive a third-party module that omits one. extractIntPart dereferenced its argument immediately, so a missing key produced a NullPointerException; the enclosing catch covered only IOException and OutOfMemoryError, so it escaped the InstalledModule constructor, escaped computeIfAbsent in the reload loop, and aborted reloadInstalledModules before it assigned installedModules. The user was left with no modules at all, from any one bad APK. Guard the null, and widen the catch so a single unreadable module is logged and skipped rather than taking the list down with it. Also normalise scope.list the way the daemon normalises its init lists. Entries are matched by exact string, so a comment line or a trailing space silently drops a package from the module's recommended scope. This one is a robustness fix rather than a conformance fix: API 101 specifies no lexical format for scope.list. --- .../org/lsposed/manager/util/ModuleUtil.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java index 1205b37c5..1ff9cfdf0 100644 --- a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java +++ b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java @@ -86,6 +86,9 @@ public static synchronized ModuleUtil getInstance() { } public static int extractIntPart(String str) { + // minApiVersion and targetApiVersion are required of a module, but a third-party module + // omitting one must not take down the module list. + if (str == null) return 0; int result = 0, length = str.length(); for (int offset = 0; offset < length; offset++) { char c = str.charAt(offset); @@ -304,13 +307,20 @@ private InstalledModule(PackageInfo pkg, ZipFile modernModuleApk) { var scopeEntry = modernModuleApk.getEntry("META-INF/xposed/scope.list"); if (scopeEntry != null) { try (var reader = new BufferedReader(new InputStreamReader(modernModuleApk.getInputStream(scopeEntry)))) { - scopeList = reader.lines().collect(Collectors.toList()); + // Scope entries are matched by exact string, so normalise the way the + // daemon normalises its init lists. + scopeList = reader.lines() + .map(String::trim) + .filter(s -> !s.isEmpty() && !s.startsWith("#")) + .collect(Collectors.toList()); } } else { scopeList = Collections.emptyList(); } - } catch (IOException | OutOfMemoryError e) { - Log.e(App.TAG, "Error while closing modern module APK", e); + } catch (Throwable e) { + // A single malformed module must not abort reloadInstalledModules and leave + // the user with an empty list. + Log.e(App.TAG, "Error while reading modern module APK", e); } this.minVersion = minVersion; this.targetVersion = targetVersion; From 60a3af24c9e92195924e8a0beaf20e83b70a4b6d Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 06:37:33 +0200 Subject: [PATCH 05/25] Honour the clear flag of a remote preference diff RemotePreferences.Editor always writes a "clear" boolean into the diff bundle it sends, and sets it for edit().clear(). Neither end read it. On the daemon side, edit().clear() therefore left every existing key in the database, so a module app clearing a group and writing a fresh set ended up with the union of both. Delete the group before applying the rest of the diff. In hooked processes, VectorRemotePreferences.onUpdate inspected only "delete" and "put", so a cleared group kept serving its old values from the in-process cache and no OnSharedPreferenceChangeListener fired. Clear the cache and report every dropped key as a change. deleteRemotePreferences had no notification path at all, so hooked processes kept a deleted group alive until their process restarted. Now that the receiver understands "clear", send it. --- .../org/matrix/vector/daemon/ipc/ModuleService.kt | 10 ++++++++++ .../org/matrix/vector/impl/VectorRemotePreferences.kt | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index 26d0a25bb..04a54fd23 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -154,6 +154,12 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { val userId = ensureModule() val values = mutableMapOf() + // RemotePreferences.Editor always writes this key, and sets it for edit().clear(). Ignoring it + // left every key the module app just cleared in place. + if (diff.getBoolean("clear", false)) { + PreferenceStore.deleteModulePrefs(loadedModule.packageName, userId, group) + } + diff.getSerializable("delete")?.let { deletes -> (deletes as Set<*>).forEach { values[it as String] = null } } @@ -170,6 +176,10 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { override fun deleteRemotePreferences(group: String) { PreferenceStore.deleteModulePrefs(loadedModule.packageName, ensureModule(), group) + // Hooked processes hold an in-process cache of the group; without this they keep serving the + // deleted values until their process restarts. + (loadedModule.service as? InjectedModuleService) + ?.onUpdateRemotePreferences(group, Bundle().apply { putBoolean("clear", true) }) } override fun listRemoteFiles(): Array { diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt index db50b0cc6..2c66241c9 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt @@ -33,6 +33,13 @@ internal class VectorRemotePreferences(service: ILSPInjectedModuleService, group override fun onUpdate(bundle: Bundle) { val changes = ArraySet() + // Sent for edit().clear() and for deleteRemotePreferences. Without this the cache + // keeps serving values the module app already removed. + if (bundle.getBoolean("clear", false)) { + changes.addAll(map.keys) + map.clear() + } + if (bundle.containsKey("delete")) { val deletes = bundle.getSerializableCompat>("delete") as? Set From 8a3eb12ece34d4481080f686c25459be21d22428 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 06:37:33 +0200 Subject: [PATCH 06/25] Always answer a scope request through its callback IXposedScopeCallback is the only way a module learns the outcome of requestScope, and two paths left it silent or violated its contract. An empty package list fell through the forEach without dispatching anything, so the module waited for a callback that could never arrive. Nothing was requested, so answer onScopeRequestApproved with an empty list. The generic failure path forwarded Throwable.message, which is routinely null, into onScopeRequestFailed(@NonNull String). The library stub passes it straight to the module's listener, so a module that trusts the annotation crashes inside the binder callback. Fall back to toString(). --- .../main/kotlin/org/matrix/vector/daemon/VectorService.kt | 3 ++- .../kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 1306ff550..48747e111 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -396,7 +396,8 @@ object VectorService : IDaemonService.Stub() { } } } - .onFailure { runCatching { iCallback.onScopeRequestFailed(it.message) } } + // onScopeRequestFailed declares @NonNull, and Throwable.message is frequently null. + .onFailure { runCatching { iCallback.onScopeRequestFailed(it.message ?: it.toString()) } } NotificationManager.cancelNotification( NotificationManager.SCOPE_CHANNEL_ID, packageName, userId) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index 04a54fd23..b60e7e62c 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -123,6 +123,12 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { override fun requestScope(packages: List, callback: IXposedScopeCallback) { val userId = ensureModule() + if (packages.isEmpty()) { + // Nothing was asked for, so the request is trivially satisfied. Returning without touching + // the callback would leave the module waiting forever. + callback.onScopeRequestApproved(emptyList()) + return + } if (!PreferenceStore.isScopeRequestBlocked(loadedModule.packageName)) { packages.forEach { pkg -> NotificationManager.requestModuleScope(loadedModule.packageName, userId, pkg, callback) From dbdf45f4ce102e11150aba8bd1f16b2246d62ce3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 06:38:55 +0200 Subject: [PATCH 07/25] Give hookers the executable's own exception from an invoker chain Chain#proceed() documents "@throws Throwable if any interceptor or the original executable throws an exception". The terminal that BaseInvoker builds for Invoker.Type.Chain called invokeOriginalMethod raw, i.e. through Method.invoke, so the original's exception reached the hooker wrapped in InvocationTargetException. The ordinary hooked-call path already unwraps it, and so does the Origin invoker path, so a hooker's catch clause worked or did not depending on how the call happened to be entered. Route the chain terminal through the same helper. --- .../main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt index 76db9a775..edfeca033 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt @@ -49,10 +49,10 @@ internal abstract class BaseInvoker, U : Executable>( val delegate = VectorBootstrap.delegate if (legacyHooks.isNotEmpty() && delegate != null) { delegate.processLegacyHook(executable, tObj, tArgs, legacyHooks) { - HookBridge.invokeOriginalMethod(executable, tObj, *tArgs) + invokeOriginal(tObj, tArgs) } } else { - HookBridge.invokeOriginalMethod(executable, tObj, *tArgs) + invokeOriginal(tObj, tArgs) } } From 3da31f3c42d78c693a3fdb5685e47247af05dfe0 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 07:39:02 +0200 Subject: [PATCH 08/25] Resolve ExceptionMode.DEFAULT from module.prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT is documented as "Follows the global exception mode configured in module.prop. Defaults to PROTECTIVE if not specified", and package-info names the key and its values: exceptionMode (string) [protective| passthrough]. Nothing in the tree read it, so DEFAULT silently meant PROTECTIVE and a module could not opt into passthrough globally. The daemon is the only process that can read module.prop, so it normalises the key to a boolean on PreLoadedApk, which already carries the per-module legacy flag to the injected process. VectorModuleManager turns that into the enum for the module's VectorContext, and the builder resolves DEFAULT at hook registration. Registration, not throw time: the record is stored natively and reaches VectorChain with no way back to the module, and module.prop cannot change for the life of the process. VectorChain is untouched — once records hold a concrete mode its existing PASSTHROUGH test is right. Framework-internal hooks build VectorHookBuilder directly with no module and keep PROTECTIVE through the constructor default. Letting one of those propagate would take the boot path down with it. --- .../org/matrix/vector/daemon/data/FileSystem.kt | 8 ++++++++ .../aidl/org/lsposed/lspd/models/PreLoadedApk.aidl | 3 +++ .../kotlin/org/matrix/vector/impl/VectorContext.kt | 7 +++++-- .../matrix/vector/impl/core/VectorModuleManager.kt | 4 ++++ .../matrix/vector/impl/hooks/VectorNativeHooker.kt | 14 ++++++++++++-- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index 2a555bcfc..9f86dcee9 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -175,6 +175,7 @@ object FileSystem { val moduleClassNames = mutableListOf() val moduleLibraryNames = mutableListOf() var isLegacy = false + var exceptionPassthrough = false runCatching { ZipFile(file).use { zip -> @@ -196,6 +197,12 @@ object FileSystem { // Leading-digit parsing, matching ModuleUtil.extractIntPart in the manager, so the two // sides cannot disagree about a value like "101.0". val targetApi = leadingInt(props.getProperty("targetApiVersion")) + // The module-wide mode ExceptionMode.DEFAULT resolves to. Anything that is not + // "passthrough" - absent, misspelled, or an explicit "protective" - keeps the + // protective default the API specifies. + exceptionPassthrough = + props.getProperty("exceptionMode")?.trim().equals("passthrough", true) + val hasLegacyFile = zip.getEntry("assets/xposed_init") != null // Determine Loading Strategy based on Priority: API 101+ > Legacy > API 100 @@ -272,6 +279,7 @@ object FileSystem { this.moduleClassNames = moduleClassNames this.moduleLibraryNames = moduleLibraryNames this.legacy = isLegacy + this.exceptionPassthrough = exceptionPassthrough } return preLoadedApk diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl index a9b5b9ef3..45a96ce3b 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl @@ -5,4 +5,7 @@ parcelable PreLoadedApk { List moduleClassNames; List moduleLibraryNames; boolean legacy; + // module.prop 'exceptionMode', normalised by the daemon. false, the value an absent key + // parses to, is PROTECTIVE - what ExceptionMode.DEFAULT is specified to fall back to. + boolean exceptionPassthrough; } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt index d92faabb6..d5044715b 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -4,6 +4,7 @@ import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.os.ParcelFileDescriptor import io.github.libxposed.api.XposedInterface +import io.github.libxposed.api.XposedInterface.ExceptionMode import io.github.libxposed.api.XposedModuleInterface.* import java.io.FileNotFoundException import java.lang.reflect.Constructor @@ -25,6 +26,8 @@ class VectorContext( private val packageName: String, private val applicationInfo: ApplicationInfo, private val service: ILSPInjectedModuleService, + // What ExceptionMode.DEFAULT resolves to for this module, from module.prop. + private val defaultExceptionMode: ExceptionMode = ExceptionMode.PROTECTIVE, ) : XposedInterface { private val remotePrefs = ConcurrentHashMap() @@ -40,14 +43,14 @@ class VectorContext( } override fun hook(origin: Executable): XposedInterface.HookBuilder { - return VectorHookBuilder(origin) + return VectorHookBuilder(origin, defaultExceptionMode) } override fun hookClassInitializer(origin: Class<*>): XposedInterface.HookBuilder { val clinit = HookBridge.getStaticInitializer(origin) ?: throw IllegalArgumentException("Class ${origin.name} has no static initializer") - return VectorHookBuilder(clinit) + return VectorHookBuilder(clinit, defaultExceptionMode) } override fun deoptimize(executable: Executable): Boolean { diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt index a5afba016..05fda8b70 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt @@ -2,6 +2,7 @@ package org.matrix.vector.impl.core import android.os.Build import android.os.Process +import io.github.libxposed.api.XposedInterface.ExceptionMode import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam import java.io.File @@ -62,6 +63,9 @@ object VectorModuleManager { packageName = module.packageName, applicationInfo = module.applicationInfo, service = module.service, // Our IPC client + defaultExceptionMode = + if (module.file.exceptionPassthrough) ExceptionMode.PASSTHROUGH + else ExceptionMode.PROTECTIVE, ) // Instantiate the module entry classes diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt index f55f719db..c8b65fe78 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt @@ -16,7 +16,12 @@ import org.matrix.vector.impl.di.VectorBootstrap import org.matrix.vector.nativebridge.HookBridge /** Builder for configuring and registering hooks. */ -class VectorHookBuilder(private val origin: Executable) : HookBuilder { +class VectorHookBuilder( + private val origin: Executable, + // Framework-internal hooks have no module.prop, and must stay protective: letting one of + // them propagate would take the boot path down with it. + private val defaultExceptionMode: ExceptionMode = ExceptionMode.PROTECTIVE, +) : HookBuilder { private var priority = XposedInterface.PRIORITY_DEFAULT private var exceptionMode = ExceptionMode.DEFAULT @@ -48,7 +53,12 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder { throw IllegalArgumentException("Cannot hook Constructor.newInstance") } - val record = VectorHookRecord(hooker, priority, exceptionMode) + // Resolve DEFAULT here rather than at throw time: the record is stored natively and + // reaches VectorChain with no way back to the module, and module.prop cannot change + // for the life of the process. + val resolvedMode = + if (exceptionMode == ExceptionMode.DEFAULT) defaultExceptionMode else exceptionMode + val record = VectorHookRecord(hooker, priority, resolvedMode) // Register natively. HookBridge now stores VectorHookRecord instead of HookerCallback. if ( From 56be396403782df9d468678ff47ad66eb8828d98 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 07:39:02 +0200 Subject: [PATCH 09/25] Stop reporting the android:ui process as the system package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package-info says system server is the virtual package "system", and that "android package is still a valid scope target because some of its components declare android:process=":ui"". The remap fired whenever the bound package was android, which is the android:ui process — in system server currentPackageName() is null, so isFirstPackage is false and the name was already android. So the one process the remap could reach was the one that should have kept its real name. The two APIs disagree here, so keep both names. de.robv modules identify system server by lpparam.packageName == "android", and android:ui has always been handed "system" to keep those apart; changing that would make every legacy module run its system-server path a second time. The modern callbacks now get the real package name and the legacy bridge keeps the name it has always seen. --- .../vector/impl/hookers/LoadedApkHookers.kt | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hookers/LoadedApkHookers.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hookers/LoadedApkHookers.kt index 33c691988..bac9d9cc2 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hookers/LoadedApkHookers.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hookers/LoadedApkHookers.kt @@ -39,25 +39,42 @@ private object PackageContextHelper { data class ContextInfo( val packageName: String, + val legacyPackageName: String, val processName: String, val isFirstPackage: Boolean, ) fun resolve(loadedApk: Any, apkPackageName: String): ContextInfo { - var packageName = currentPkgMethod.invoke(null) as? String - var processName = currentProcMethod.invoke(null) as? String + val boundPackage = currentPkgMethod.invoke(null) as? String + val boundProcess = currentProcMethod.invoke(null) as? String val isFirstPackage = - packageName != null && processName != null && packageName == apkPackageName - - if (!isFirstPackage) { + boundPackage != null && boundProcess != null && boundPackage == apkPackageName + + val packageName: String + val processName: String + if (isFirstPackage) { + packageName = boundPackage!! + processName = boundProcess!! + } else { packageName = apkPackageName - processName = currentPkgMethod.invoke(null) as? String ?: apkPackageName - } else if (packageName == "android") { - packageName = "system" + processName = boundPackage ?: apkPackageName } - return ContextInfo(packageName, processName, isFirstPackage) + // The two APIs name this process differently, so keep both names. + // + // de.robv modules identify system server by lpparam.packageName == "android", so the + // android:ui process has always been handed "system" to keep the two apart. The modern + // API says the reverse: "system" is the virtual name for system server, while "android" + // stays a real scope target precisely because some of its components declare + // android:process=":ui". Reporting android:ui as "system" there hid the real package. + // + // In system server itself currentPackageName() is null, so isFirstPackage is false and + // both names are already "android"; only android:ui is affected. + val legacyPackageName = + if (isFirstPackage && packageName == "android") "system" else packageName + + return ContextInfo(packageName, legacyPackageName, processName, isFirstPackage) } } @@ -172,7 +189,7 @@ object LoadedApkCreateCLHooker : XposedInterface.Hooker { VectorBootstrap.withLegacy { delegate -> delegate.onPackageLoaded( LegacyPackageInfo( - ctx.packageName, + ctx.legacyPackageName, ctx.processName, classLoader, appInfo, From f0010c4ce15071186963f1175fe4bf45198b51b5 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sun, 26 Jul 2026 07:39:02 +0200 Subject: [PATCH 10/25] Report a missing or forbidden remote file as FileNotFoundException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XposedInterface#openRemoteFile documents FileNotFoundException for a file that does not exist or a path that is forbidden. The daemon threw RemoteException for the first and let ensureModuleFilePath's own RemoteException escape for the second. Modules did see FileNotFoundException, but only by accident: RemoteException is not marshallable, so the binder runtime discarded it with an "Uncaught remote exception!" stack trace and the client read a null reply, which VectorContext's elvis turned into the right exception. Every miss cost a full stack trace in the log, and the contract held only as long as nothing threw a type Parcel.writeException does support — an IllegalArgumentException would have reached modules unchanged. Return null deliberately instead, with the AIDL marked @nullable and a one-line warning, so VectorContext produces the documented exception on purpose. Also de-duplicate getScope(): the scope table has a row per (app, user), so a module enabled for several users saw the same package repeatedly. Cross-user filtering is deliberately not done here — the spec does not require it and it would change which packages a module sees. --- .../matrix/vector/daemon/ipc/InjectedModuleService.kt | 10 +++++++--- .../org/matrix/vector/daemon/ipc/ModuleService.kt | 4 +++- .../lspd/service/ILSPInjectedModuleService.aidl | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt index cff900d01..71601e2d0 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt @@ -48,14 +48,18 @@ class InjectedModuleService(private val packageName: String) : ILSPInjectedModul return bundle } - override fun openRemoteFile(path: String): ParcelFileDescriptor { - FileSystem.ensureModuleFilePath(path) + override fun openRemoteFile(path: String): ParcelFileDescriptor? { + // XposedInterface#openRemoteFile documents FileNotFoundException for a missing *or* forbidden + // path. Returning null lets VectorContext raise exactly that; throwing here surfaced a + // RemoteException for a missing file and an IllegalArgumentException for a rejected path. val userId = Binder.getCallingUid() / PER_USER_RANGE return runCatching { + FileSystem.ensureModuleFilePath(path) val dir = FileSystem.resolveModuleDir(packageName, "files", userId, -1) ParcelFileDescriptor.open(dir.resolve(path).toFile(), ParcelFileDescriptor.MODE_READ_ONLY) } - .getOrElse { throw RemoteException(it.message) } + .onFailure { Log.w(TAG, "Cannot open remote file $path for $packageName: ${it.message}") } + .getOrNull() } override fun getRemoteFileList(): Array { diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index b60e7e62c..0989af96c 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -117,7 +117,9 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { override fun getScope(): List { ensureModule() - return ConfigCache.getModuleScope(loadedModule.packageName)?.map { it.packageName } + // The scope table has one row per (app, user), so a module enabled for several users saw the + // same package repeatedly. A scope is a set of package names. + return ConfigCache.getModuleScope(loadedModule.packageName)?.map { it.packageName }?.distinct() ?: emptyList() } diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl index c77ece391..71751c3a9 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl @@ -7,7 +7,7 @@ interface ILSPInjectedModuleService { Bundle requestRemotePreferences(String group, IRemotePreferenceCallback callback); - ParcelFileDescriptor openRemoteFile(String path); + @nullable ParcelFileDescriptor openRemoteFile(String path); String[] getRemoteFileList(); } From f07c704f93f825e7eb5683cb6ea52539e7534d7e Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 08:00:08 +0200 Subject: [PATCH 11/25] Load modules into a late-injected system server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VectorModuleManager.loadModule is reachable only from XposedInit.loadModules, which only runs from the ActivityThread.attach hook. The late-inject branch is guarded on the activity service already being published, so attach completed long before we arrived and that hook can never fire — yet the branch still called dispatchSystemServerLoaded, which walks activeModules. That set was empty, so onSystemServerStarting went out to nobody and no module ran in a late-injected system server at all. Load the modules there before dispatching, and guard loadModules with a compareAndSet so the ordinary path cannot load a second generation on top. The callback is dispatched even though "system server is ready to start critical services" is no longer true this late; the services are already running. Loading the modules and staying silent would be worse — they would simply never run. Both the late load and the late dispatch are logged as such so the situation is visible in a bug report rather than inferred. --- .../de/robv/android/xposed/XposedInit.java | 7 ++++++ .../matrix/vector/impl/core/VectorStartup.kt | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java index 9c23e3380..ba197f5df 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java @@ -216,7 +216,14 @@ public static void loadLegacyModules() { }); } + private static final AtomicBoolean modulesLoaded = new AtomicBoolean(false); + public static void loadModules(ActivityThread at) { + // A late-injected system server calls this directly because its ActivityThread.attach + // already ran; guard so the normal path cannot load a second generation on top. + if (!modulesLoaded.compareAndSet(false, true)) { + return; + } var packages = (ArrayMap) XposedHelpers.getObjectField(at, "mPackages"); VectorServiceClient.INSTANCE.getModulesList().forEach(module -> { loadedModules.put(module.packageName, Optional.empty()); diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt index 4020d5e63..7614a4866 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt @@ -1,9 +1,12 @@ package org.matrix.vector.impl.core +import android.app.ActivityThread import android.os.Build import android.os.IBinder import dalvik.system.DexFile +import org.lsposed.lspd.util.Utils import org.lsposed.lspd.service.ILSPApplicationService +import org.matrix.vector.impl.di.VectorBootstrap import org.matrix.vector.impl.hookers.* import org.matrix.vector.impl.hooks.VectorHookBuilder @@ -82,6 +85,27 @@ object VectorStartup { if (classLoader != null) { // Manually trigger the routines that the hooks normally would HandleSystemServerProcessHooker.initSystemServer(classLoader, isLate = true) + + // ActivityThread.attach ran before we were injected, so AppAttachHooker will + // never fire here and nothing else instantiates modules. Without this, + // dispatchSystemServerLoaded below would notify an empty module set. + val activityThread = ActivityThread.currentActivityThread() + if (activityThread != null) { + VectorBootstrap.withLegacy { it.loadModules(activityThread) } + } else { + Utils.logW( + "Late system server injection: no current ActivityThread, modules cannot be loaded" + ) + } + + // Say plainly what this is. onSystemServerStarting is documented as "system + // server is ready to start critical services", which is no longer true here - + // the services are already running. Modules that key off it should treat a + // late dispatch as best effort. + Utils.logW( + "Late system server injection: dispatching onSystemServerStarting after " + + "system server has already started; critical services are live" + ) StartBootstrapServicesHooker.dispatchSystemServerLoaded(classLoader) } } From f152873d00144bf4a71e00d009469bfb35376ddc Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 08:00:08 +0200 Subject: [PATCH 12/25] Invoke an unhooked constructor through the non-virtual path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invokeOriginalMethod dispatches through a jmethodID cached once from java.lang.reflect.Method.invoke. When the executable is hooked that id is applied to lsplant's backup Method and is correct. When there is no hook item at all it is applied to the reflected object the caller passed in, and for a constructor that object is a java.lang.reflect.Constructor — a receiver the id does not belong to. Under CheckJNI that aborts; otherwise it indexes the wrong vtable slot. The native comment calls that fallback rare, which holds for the hook callback path but not for invokers: getInvoker(ctor).invoke(obj) on a constructor nobody hooked reaches it every time. Fix it in Kotlin rather than JNI. BaseInvoker already distinguishes the unhooked case, and already has the shorty builder and the non-virtual entry point that invokeSpecial uses, so an unhooked constructor is routed through invokeSpecialMethod and everything else is untouched. --- .../matrix/vector/impl/hooks/BaseInvoker.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt index edfeca033..748e50414 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt @@ -65,6 +65,26 @@ internal abstract class BaseInvoker, U : Executable>( /** Invokes the original executable, unwrapping the reflective wrapper exception. */ private fun invokeOriginal(thisObject: Any?, args: Array): Any? { + // invokeOriginalMethod dispatches through a cached Method.invoke id. For a hooked + // executable that is applied to lsplant's backup Method, which is correct. For an + // executable with no hook item at all it is applied to the reflected object we passed in + // — and if that is a Constructor, the id belongs to a different class. Route those + // through the non-virtual path instead, which is what invokeSpecial already uses. + if ( + executable is Constructor<*> && + HookBridge.callbackSnapshot(VectorHookRecord::class.java, executable) == null + ) { + requireNotNull(thisObject) { + "A constructor invoked as a method needs a receiver: $executable" + } + return HookBridge.invokeSpecialMethod( + executable, + getExecutableShorty(), + executable.declaringClass, + thisObject, + *args, + ) + } return try { HookBridge.invokeOriginalMethod(executable, thisObject, *args) } catch (e: InvocationTargetException) { From d905c2958850f4c966f25b5e27b131b82a88c291 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 08:17:08 +0200 Subject: [PATCH 13/25] Stop hookClassInitializer from aborting the process Any call to hookClassInitializer killed the target process outright: JNI DETECTED ERROR IN APPLICATION: attempt to return an instance of java.lang.reflect.Constructor from java.lang.reflect.Method org.matrix.vector.nativebridge.HookBridge.getStaticInitializer(java.lang.Class) ART reflects as a java.lang.reflect.Constructor, not a Method, so ToReflectedMethod returns a Constructor while the Kotlin declaration promised a Method, and the JNI return-type check aborts with SIGABRT. Measured on a Pixel 6 running Android 17: signal 6, tombstone, process gone. Widen the declaration to the common supertype and match the descriptor used to register the native method. hook() already takes an Executable, so nothing downstream changes. This only removes the crash. The hook still cannot fire, because resolving through GetStaticMethodID initialises the class first - measured, the class goes from uninitialised to initialised across the hookClassInitializer call itself, and the hooker is never entered afterwards. Fixing that needs a non-initialising lookup, which is tracked separately. --- native/src/jni/hook_bridge.cpp | 2 +- .../kotlin/org/matrix/vector/nativebridge/HookBridge.kt | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index 5bc19975f..b71307b8c 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -594,7 +594,7 @@ static JNINativeMethod gMethods[] = { "(Ljava/lang/Class;Ljava/lang/reflect/" "Executable;)[[Ljava/lang/Object;"), VECTOR_NATIVE_METHOD(HookBridge, getStaticInitializer, - "(Ljava/lang/Class;)Ljava/lang/reflect/Method;"), + "(Ljava/lang/Class;)Ljava/lang/reflect/Executable;"), }; /** diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index 08eb5c421..2b3e4fd6c 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -61,5 +61,11 @@ object HookBridge { method: Executable, ): Array>? - @JvmStatic external fun getStaticInitializer(clazz: Class<*>): Method? + /** + * ART reflects as a Constructor, not a Method, so this returns the + * common supertype. Declaring Method made the JNI return-type check abort the + * process on every call. + */ + @JvmStatic + external fun getStaticInitializer(clazz: Class<*>): Executable? } From 087f0ff14be59b017ce4ff26212bc0b3540a0e10 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 09:02:52 +0200 Subject: [PATCH 14/25] Implement hookClassInitializer without initializing the class The API exists to run code before a class sets up its static state, but resolving through GetStaticMethodID runs the static initializer during the lookup, so the hook was always installed too late to fire. Locate the method from ART's layout instead. A class's ArtMethods live in one contiguous array, direct methods first, ordered by dex method id; dex method ids are ordered by name and "" sorts before "", so a class that has a static initializer keeps it in the first slot, one stride below the lowest method reflection can see. Reflection over declared members does not initialize the class, so the whole path is free of the side effect. ArtMethod addresses are read from java.lang.reflect.Executable.artMethod rather than through jmethodIDs, because a Java-debuggable process hands out index based ids instead of pointers and the arithmetic would be meaningless there. A class with no static initializer has something unrelated in that slot, and reflecting it walks a bogus pointer and takes the process down. The candidate is therefore checked by two plain word reads first: its declaring class must match its neighbour's, and its access flags must say static constructor. Nothing dereferences it until both hold. Measured on a Pixel 6 running Android 17, on debuggable and non-debuggable targets alike: the class stays uninitialized across the lookup, the hooker runs ahead of the class's own initializer, and a class with no static initializer is refused instead of crashing. --- native/src/jni/hook_bridge.cpp | 79 +++++++++++++++++++ .../org/matrix/vector/impl/VectorContext.kt | 32 +++++++- .../matrix/vector/nativebridge/HookBridge.kt | 11 +++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index b71307b8c..6f570e096 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -1,9 +1,12 @@ #include #include +#include #include +#include #include #include +#include #include "jni/jni_bridge.h" #include "jni/jni_hooks.h" @@ -558,6 +561,80 @@ VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, callbackSnapshot, jclass call * @param target_class The class to inspect. * @return A Method object for the static initializer, or null if it doesn't exist. */ +/** + * @brief Finds a class's static initializer without initializing the class. + * + * GetStaticMethodID cannot be used: JNI specifies that resolving a method id initializes the + * class, which is exactly the event a hook exists to observe. Java reflection cannot be + * used either, because it hides entirely. + * + * ART stores a class's ArtMethods in one contiguous array, direct methods first, ordered by dex + * method id. Dex method ids are ordered by name and "" sorts before "", so a class + * that has a static initializer keeps it in the first slot - one stride below the lowest method + * ordinary reflection can see. + * + * The caller passes ArtMethod addresses read from java.lang.reflect.Executable.artMethod rather + * than jmethodIDs, because a Java-debuggable process hands out index based ids instead of + * pointers. + * + * A class with no static initializer has something else in that slot, so the candidate is checked + * by two plain word reads before anything dereferences it: its declaring class must match its + * neighbour's, and its access flags must say static constructor. + * + * @return The reflected static initializer, or nullptr if the class has none or the layout is not + * what this relies on. + */ +VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, findStaticInitializer, jclass target_class, + jlongArray art_methods) { + const jsize count = art_methods ? env->GetArrayLength(art_methods) : 0; + // One member anchors the array, a second gives its element size. + if (count < 2) return nullptr; + + std::vector ids(count); + { + std::vector raw(count); + env->GetLongArrayRegion(art_methods, 0, count, raw.data()); + for (jsize i = 0; i < count; ++i) { + auto id = static_cast(raw[i]); + if (id < 0x1000 || (id % alignof(void *)) != 0) return nullptr; + ids[i] = id; + } + } + + std::sort(ids.begin(), ids.end()); + uintptr_t stride = std::numeric_limits::max(); + for (size_t i = 1; i < ids.size(); ++i) { + uintptr_t delta = ids[i] - ids[i - 1]; + if (delta > 0 && delta < stride) stride = delta; + } + // An ArtMethod is a few dozen bytes on every supported release; refuse rather than guess when + // the spacing is not what a contiguous method array looks like. + if (stride < 16 || stride > 128 || (stride % alignof(void *)) != 0) return nullptr; + for (size_t i = 1; i < ids.size(); ++i) { + if ((ids[i] - ids[i - 1]) % stride != 0) return nullptr; + } + + const uintptr_t anchor = ids.front(); + const uintptr_t candidate = anchor - stride; + + // ArtMethod starts with GcRoot declaring_class_ followed by uint32_t + // access_flags_. Both are plain words in the same allocation as the anchor, so reading them + // cannot fault where reading the anchor would not. + const auto declaring_of = [](uintptr_t m) { return *reinterpret_cast(m); }; + const auto flags_of = [](uintptr_t m) { + return *reinterpret_cast(m + sizeof(uint32_t)); + }; + + if (declaring_of(candidate) != declaring_of(anchor)) return nullptr; + + constexpr uint32_t kAccStatic = 0x0008; + constexpr uint32_t kAccConstructor = 0x00010000; + const uint32_t flags = flags_of(candidate); + if ((flags & kAccStatic) == 0 || (flags & kAccConstructor) == 0) return nullptr; + + return env->ToReflectedMethod(target_class, reinterpret_cast(candidate), JNI_TRUE); +} + VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, getStaticInitializer, jclass target_class) { // is the internal name for a static initializer. // Its signature is always ()V (no arguments, void return). @@ -595,6 +672,8 @@ static JNINativeMethod gMethods[] = { "Executable;)[[Ljava/lang/Object;"), VECTOR_NATIVE_METHOD(HookBridge, getStaticInitializer, "(Ljava/lang/Class;)Ljava/lang/reflect/Executable;"), + VECTOR_NATIVE_METHOD(HookBridge, findStaticInitializer, + "(Ljava/lang/Class;[J)Ljava/lang/reflect/Executable;"), }; /** diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt index d5044715b..0743beb92 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -9,6 +9,7 @@ import io.github.libxposed.api.XposedModuleInterface.* import java.io.FileNotFoundException import java.lang.reflect.Constructor import java.lang.reflect.Executable +import java.lang.reflect.Field import java.lang.reflect.Method import java.util.concurrent.ConcurrentHashMap import org.lsposed.lspd.service.ILSPInjectedModuleService @@ -18,6 +19,16 @@ import org.matrix.vector.impl.hooks.VectorHookBuilder import org.matrix.vector.impl.hooks.VectorMethodInvoker import org.matrix.vector.nativebridge.HookBridge +private const val TAG = "VectorContext" + +/** ART keeps the ArtMethod address of a reflected member in this field. */ +private val artMethodField: Field? by lazy { + runCatching { + Executable::class.java.getDeclaredField("artMethod").apply { isAccessible = true } + } + .getOrNull() +} + /** * Main framework context implementation. Provides modules with capabilities to hook executables, * request invokers, and interact with the system. @@ -48,11 +59,30 @@ class VectorContext( override fun hookClassInitializer(origin: Class<*>): XposedInterface.HookBuilder { val clinit = - HookBridge.getStaticInitializer(origin) + findStaticInitializer(origin) ?: throw IllegalArgumentException("Class ${origin.name} has no static initializer") return VectorHookBuilder(clinit, defaultExceptionMode) } + /** + * Resolving through JNI runs the class's static initializer, which is the event a hook + * on it exists to observe, so locate it from the method layout instead. Reflection over + * declared members does not initialize the class, and ArtMethod addresses are read from the + * reflected objects rather than through jmethodIDs, which a debuggable process hands out as + * indices. + */ + private fun findStaticInitializer(origin: Class<*>): Executable? = + runCatching { + val field = artMethodField ?: return null + val members = ArrayList() + members.addAll(origin.declaredConstructors) + members.addAll(origin.declaredMethods) + val addresses = LongArray(members.size) { field.getLong(members[it]) } + HookBridge.findStaticInitializer(origin, addresses) + } + .onFailure { Log.w(TAG, "Static initializer lookup failed for ${origin.name}", it) } + .getOrNull() + override fun deoptimize(executable: Executable): Boolean { return HookBridge.deoptimizeMethod(executable) } diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index 2b3e4fd6c..4c00a1f64 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -68,4 +68,15 @@ object HookBridge { */ @JvmStatic external fun getStaticInitializer(clazz: Class<*>): Executable? + + /** + * Locates a class's static initializer without initializing it, unlike [getStaticInitializer]. + * [artMethods] must be the ArtMethod addresses of the class's declared constructors and + * methods, which reflection can supply without triggering initialization. + * + * Returns null when the class has no static initializer or the method layout is not the one + * this relies on. + */ + @JvmStatic + external fun findStaticInitializer(clazz: Class<*>, artMethods: LongArray): Executable? } From ec703e0eef9776c8d187619970081296bc0404dc Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 09:11:32 +0200 Subject: [PATCH 15/25] Drop the JNI static initializer lookup findStaticInitializer replaced its only caller. Leaving it behind would keep a function that initializes any class it is asked about, which is the behaviour the previous commit exists to avoid. --- native/src/jni/hook_bridge.cpp | 17 ----------------- .../matrix/vector/nativebridge/HookBridge.kt | 10 +--------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index 6f570e096..2f806421a 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -635,21 +635,6 @@ VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, findStaticInitializer, jclass targ return env->ToReflectedMethod(target_class, reinterpret_cast(candidate), JNI_TRUE); } -VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, getStaticInitializer, jclass target_class) { - // is the internal name for a static initializer. - // Its signature is always ()V (no arguments, void return). - jmethodID mid = env->GetStaticMethodID(target_class, "", "()V"); - if (!mid) { - // If GetStaticMethodID fails, it throws an exception. - // We clear it and return null to let the Java side handle it gracefully. - env->ExceptionClear(); - return nullptr; - } - // Convert the method ID to a java.lang.reflect.Method object. - // The last parameter must be JNI_TRUE because it's a static method. - return env->ToReflectedMethod(target_class, mid, JNI_TRUE); -} - // Array of native method descriptors for JNI registration. static JNINativeMethod gMethods[] = { VECTOR_NATIVE_METHOD(HookBridge, hookMethod, @@ -670,8 +655,6 @@ static JNINativeMethod gMethods[] = { VECTOR_NATIVE_METHOD(HookBridge, callbackSnapshot, "(Ljava/lang/Class;Ljava/lang/reflect/" "Executable;)[[Ljava/lang/Object;"), - VECTOR_NATIVE_METHOD(HookBridge, getStaticInitializer, - "(Ljava/lang/Class;)Ljava/lang/reflect/Executable;"), VECTOR_NATIVE_METHOD(HookBridge, findStaticInitializer, "(Ljava/lang/Class;[J)Ljava/lang/reflect/Executable;"), }; diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index 4c00a1f64..c286831a3 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -62,15 +62,7 @@ object HookBridge { ): Array>? /** - * ART reflects as a Constructor, not a Method, so this returns the - * common supertype. Declaring Method made the JNI return-type check abort the - * process on every call. - */ - @JvmStatic - external fun getStaticInitializer(clazz: Class<*>): Executable? - - /** - * Locates a class's static initializer without initializing it, unlike [getStaticInitializer]. + * Locates a class's static initializer without initializing it. * [artMethods] must be the ArtMethod addresses of the class's declared constructors and * methods, which reflection can supply without triggering initialization. * From 59a93b446aeb760dc7e66fbe5ad542c9134480af Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 10:59:39 +0200 Subject: [PATCH 16/25] Find a static initializer by the gap in the method run The lookup assumed was the first ArtMethod of a class, one stride below the lowest member reflection can see. Dex method ids are ordered by name and "" does sort before "", but not before everything: '$' and '-' come earlier still. dexdump of a release build shows what that means in practice. Clinit$Colour direct: $values tag valueOf values Clinit$Nestmate direct: -$$Nest$smsecret secret So every enum, and every class whose private member is reached from a nested class, kept its static initializer in the second slot. The old arithmetic read one slot below the array on those, and missed the method it was looking for. Reflection reports each declared direct and virtual method except , and ART lays those out contiguously with copied methods after them, so the addresses are a run of evenly spaced slots with one hole in it. The hole is the method, and it is bracketed by two members that are inside the array, so nothing needs to be assumed about where the array begins. Only a class whose initializer really is first has no hole, and that one candidate is read solely once msync says its page is mapped. The element size no longer comes from the target class either, so a class whose only members are and an implicit constructor is no longer refused for showing a single member. That is the shape most worth hooking. --- native/src/jni/hook_bridge.cpp | 104 ++++++++++++------ .../org/matrix/vector/impl/VectorContext.kt | 27 ++++- .../matrix/vector/nativebridge/HookBridge.kt | 10 +- 3 files changed, 103 insertions(+), 38 deletions(-) diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index 2f806421a..2cbae6777 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #include #include @@ -557,10 +559,19 @@ VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, callbackSnapshot, jclass call } /** - * @brief Retrieves the static initializer () of a class as a Method object. - * @param target_class The class to inspect. - * @return A Method object for the static initializer, or null if it doesn't exist. + * @brief Reports whether the pages spanning [addr, addr + len) are mapped. + * + * msync on an unmapped range fails with ENOMEM, which turns a read that would raise SIGSEGV into + * an answer. Used for the one candidate below that cannot be bracketed by known-good members. */ +static bool IsMapped(uintptr_t addr, size_t len) { + static const size_t page = static_cast(sysconf(_SC_PAGESIZE)); + if (page == 0) return false; + const uintptr_t start = addr & ~(page - 1); + const size_t span = ((addr + len) - start + page - 1) & ~(page - 1); + return msync(reinterpret_cast(start), span, MS_ASYNC) == 0; +} + /** * @brief Finds a class's static initializer without initializing the class. * @@ -568,27 +579,39 @@ VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, callbackSnapshot, jclass call * class, which is exactly the event a hook exists to observe. Java reflection cannot be * used either, because it hides entirely. * - * ART stores a class's ArtMethods in one contiguous array, direct methods first, ordered by dex - * method id. Dex method ids are ordered by name and "" sorts before "", so a class - * that has a static initializer keeps it in the first slot - one stride below the lowest method - * ordinary reflection can see. + * ART stores a class's ArtMethods in one contiguous array: direct methods, then declared virtual + * methods, then methods copied in from interfaces. Reflection reports every one of the first two + * groups except , so the addresses the caller passes are a run of evenly spaced slots with + * missing from it. Finding the hole finds the method, and a hole is bracketed by two + * members that are known to be inside the array, so nothing has to be assumed about where the + * array begins. + * + * The hole is only at the very start - below every address the caller can see - when no declared + * direct method sorts ahead of "". Dex method ids are ordered by name, and while "" + * does sort after it, '$' and '-' do not: an enum's $values, and the -$$Nest$ accessors javac + * emits for nestmates, both take the first slot instead. So the slot below the run is one + * possibility among several rather than the answer, and it is the only one that can fall outside + * the array, which is what a class with no static initializer looks like. It is checked last and + * only once its page is known to be mapped. + * + * Every candidate is then confirmed by two plain word reads before anything dereferences it: its + * declaring class must match the run's, and its access flags must say static constructor. * * The caller passes ArtMethod addresses read from java.lang.reflect.Executable.artMethod rather * than jmethodIDs, because a Java-debuggable process hands out index based ids instead of * pointers. * - * A class with no static initializer has something else in that slot, so the candidate is checked - * by two plain word reads before anything dereferences it: its declaring class must match its - * neighbour's, and its access flags must say static constructor. - * - * @return The reflected static initializer, or nullptr if the class has none or the layout is not - * what this relies on. + * @return The static initializer as a reflected object, or nullptr if the class has none or the + * layout is not what this relies on. */ VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, findStaticInitializer, jclass target_class, - jlongArray art_methods) { + jlongArray art_methods, jlong art_method_size) { const jsize count = art_methods ? env->GetArrayLength(art_methods) : 0; - // One member anchors the array, a second gives its element size. - if (count < 2) return nullptr; + // One member is enough to anchor the run; the element size is a property of the runtime, so + // the caller derives it once elsewhere. A class whose only members are and an + // implicit constructor leaves exactly one member visible to reflection, and that is the + // commonest shape for wanting this hook. + if (count < 1) return nullptr; std::vector ids(count); { @@ -602,37 +625,48 @@ VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, findStaticInitializer, jclass targ } std::sort(ids.begin(), ids.end()); - uintptr_t stride = std::numeric_limits::max(); - for (size_t i = 1; i < ids.size(); ++i) { - uintptr_t delta = ids[i] - ids[i - 1]; - if (delta > 0 && delta < stride) stride = delta; - } + const auto stride = static_cast(art_method_size); // An ArtMethod is a few dozen bytes on every supported release; refuse rather than guess when - // the spacing is not what a contiguous method array looks like. + // the size is not one a contiguous method array could have. if (stride < 16 || stride > 128 || (stride % alignof(void *)) != 0) return nullptr; + + // Slots the caller cannot see. Interior ones come first because each is bracketed by a member + // on either side, so it is inside the array whatever the class turns out to look like. A class + // may have more than one hole: reflection also hides members the hidden API policy blocks. + constexpr size_t kMaxCandidates = 64; + std::vector candidates; for (size_t i = 1; i < ids.size(); ++i) { - if ((ids[i] - ids[i - 1]) % stride != 0) return nullptr; + const uintptr_t delta = ids[i] - ids[i - 1]; + // Uneven spacing means these are not one run of ArtMethods and none of this holds. + if (delta == 0 || (delta % stride) != 0) return nullptr; + for (uintptr_t slot = ids[i - 1] + stride; slot < ids[i]; slot += stride) { + if (candidates.size() >= kMaxCandidates) return nullptr; + candidates.push_back(slot); + } } - - const uintptr_t anchor = ids.front(); - const uintptr_t candidate = anchor - stride; + // The slot below the run, which may be outside the array altogether. + const uintptr_t below = ids.front() - stride; + if (IsMapped(below, 2 * sizeof(uint32_t))) candidates.push_back(below); // ArtMethod starts with GcRoot declaring_class_ followed by uint32_t - // access_flags_. Both are plain words in the same allocation as the anchor, so reading them - // cannot fault where reading the anchor would not. + // access_flags_, so both live in the first eight bytes of a slot. const auto declaring_of = [](uintptr_t m) { return *reinterpret_cast(m); }; const auto flags_of = [](uintptr_t m) { return *reinterpret_cast(m + sizeof(uint32_t)); }; - if (declaring_of(candidate) != declaring_of(anchor)) return nullptr; - constexpr uint32_t kAccStatic = 0x0008; constexpr uint32_t kAccConstructor = 0x00010000; - const uint32_t flags = flags_of(candidate); - if ((flags & kAccStatic) == 0 || (flags & kAccConstructor) == 0) return nullptr; - - return env->ToReflectedMethod(target_class, reinterpret_cast(candidate), JNI_TRUE); + const uint32_t declaring = declaring_of(ids.front()); + + for (const uintptr_t candidate : candidates) { + if (declaring_of(candidate) != declaring) continue; + const uint32_t flags = flags_of(candidate); + if ((flags & kAccStatic) == 0 || (flags & kAccConstructor) == 0) continue; + return env->ToReflectedMethod(target_class, reinterpret_cast(candidate), + JNI_TRUE); + } + return nullptr; } // Array of native method descriptors for JNI registration. @@ -656,7 +690,7 @@ static JNINativeMethod gMethods[] = { "(Ljava/lang/Class;Ljava/lang/reflect/" "Executable;)[[Ljava/lang/Object;"), VECTOR_NATIVE_METHOD(HookBridge, findStaticInitializer, - "(Ljava/lang/Class;[J)Ljava/lang/reflect/Executable;"), + "(Ljava/lang/Class;[JJ)Ljava/lang/reflect/Executable;"), }; /** diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt index 0743beb92..fec66e36e 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -21,6 +21,28 @@ import org.matrix.vector.nativebridge.HookBridge private const val TAG = "VectorContext" +/** Smallest positive gap between the sorted addresses, i.e. one ArtMethod. */ +private fun strideOf(addresses: LongArray): Long { + val sorted = addresses.sorted() + var stride = Long.MAX_VALUE + for (i in 1 until sorted.size) { + val delta = sorted[i] - sorted[i - 1] + if (delta in 1 until stride) stride = delta + } + return if (stride == Long.MAX_VALUE) 0L else stride +} + +/** + * The size of one ArtMethod, which is a property of the runtime rather than of any class. Derived + * once from a class with plenty of members so that a class showing only one member to reflection + * can still be measured against it. + */ +private val artMethodSize: Long by lazy { + val field = artMethodField ?: return@lazy 0L + runCatching { strideOf(Any::class.java.declaredMethods.map { field.getLong(it) }.toLongArray()) } + .getOrDefault(0L) +} + /** ART keeps the ArtMethod address of a reflected member in this field. */ private val artMethodField: Field? by lazy { runCatching { @@ -78,7 +100,10 @@ class VectorContext( members.addAll(origin.declaredConstructors) members.addAll(origin.declaredMethods) val addresses = LongArray(members.size) { field.getLong(members[it]) } - HookBridge.findStaticInitializer(origin, addresses) + // Prefer the class's own spacing; fall back to the runtime-wide size for a class + // that shows fewer than two members. + val stride = strideOf(addresses).takeIf { it > 0 } ?: artMethodSize + HookBridge.findStaticInitializer(origin, addresses, stride) } .onFailure { Log.w(TAG, "Static initializer lookup failed for ${origin.name}", it) } .getOrNull() diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index c286831a3..d469eb4a5 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -64,11 +64,17 @@ object HookBridge { /** * Locates a class's static initializer without initializing it. * [artMethods] must be the ArtMethod addresses of the class's declared constructors and - * methods, which reflection can supply without triggering initialization. + * methods, which reflection can supply without triggering initialization, and [artMethodSize] + * the size of one ArtMethod. One member is enough, which matters because a class whose only + * members are the static initializer and an implicit constructor shows just one to reflection. * * Returns null when the class has no static initializer or the method layout is not the one * this relies on. */ @JvmStatic - external fun findStaticInitializer(clazz: Class<*>, artMethods: LongArray): Executable? + external fun findStaticInitializer( + clazz: Class<*>, + artMethods: LongArray, + artMethodSize: Long, + ): Executable? } From 423b0946059d58462e95e19ab38dc042732d286b Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 11:00:22 +0200 Subject: [PATCH 17/25] Hand the hooker a Method for a static initializer hookClassInitializer says the Chain passed to the hooker reports "a synthetic Method representing the static initializer". ART reflects as a Constructor, because it carries ACC_CONSTRUCTOR, so the hooker was given a type it could not cast. A Method is now allocated without running a constructor and given the same Executable state, naming the same ArtMethod under the documented type. Measured on a Pixel 6 running Android 17: executable=java.lang.reflect.Method name= declaring=Colour thisObject=null args=[] proceed=null which is the four bullets that section lists. --- .../org/matrix/vector/impl/VectorContext.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt index fec66e36e..efb4da78f 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -11,6 +11,7 @@ import java.lang.reflect.Constructor import java.lang.reflect.Executable import java.lang.reflect.Field import java.lang.reflect.Method +import java.lang.reflect.Modifier import java.util.concurrent.ConcurrentHashMap import org.lsposed.lspd.service.ILSPInjectedModuleService import org.lsposed.lspd.util.Utils.Log @@ -83,7 +84,32 @@ class VectorContext( val clinit = findStaticInitializer(origin) ?: throw IllegalArgumentException("Class ${origin.name} has no static initializer") - return VectorHookBuilder(clinit, defaultExceptionMode) + return VectorHookBuilder(asSyntheticMethod(clinit), defaultExceptionMode) + } + + /** + * hookClassInitializer documents Chain#getExecutable as "a synthetic Method representing the + * static initializer", but ART reflects as a Constructor because it carries + * ACC_CONSTRUCTOR. A Method is allocated without running a constructor and given the same + * Executable state, so it names the same ArtMethod while presenting the documented type. + */ + private fun asSyntheticMethod(clinit: Executable): Executable { + if (clinit is Method) return clinit + return runCatching { + val method = HookBridge.allocateObject(Method::class.java) + for (field in Executable::class.java.declaredFields) { + if (Modifier.isStatic(field.modifiers)) continue + field.isAccessible = true + field.set(method, field.get(clinit)) + } + method as Executable + } + .onFailure { + // Falling back keeps the hook working, at the cost of handing the hooker the type + // ART produced instead of the one the interface promises. + Log.w(TAG, "Cannot present of ${'$'}{clinit.declaringClass.name} as a Method", it) + } + .getOrDefault(clinit) } /** From 9a0fcd5fe2616ff6383d61f82f08ec45a8a10891 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 11:00:22 +0200 Subject: [PATCH 18/25] Report an invoker's target exception the way Method.invoke does Invoker#invoke and #invokeSpecial are declared to throw InvocationTargetException and documented against Method#invoke, so the executable's own exception belongs inside one. It arrived that way from an unhooked constructor and raw from everything else, which is the kind of difference a module only finds in production. Chain#proceed is documented the other way round - it throws whatever the executable threw - so the wrapping goes at the public boundary and the chain terminal keeps unwrapping for hookers. The paths that skip the chain get it from the native dispatch, which already separates a target exception from its own argument failures. PASS invoker-wraps-unhooked ITE wrapping IllegalStateException PASS invoker-wraps-hooked ITE wrapping IllegalStateException PASS invoker-wraps-unhooked-ctor ITE wrapping IllegalStateException --- .../matrix/vector/impl/hooks/BaseInvoker.kt | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt index 748e50414..a8f8e2564 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt @@ -28,14 +28,16 @@ internal abstract class BaseInvoker, U : Executable>( /** Resolves the current [type] and executes the underlying method. */ protected fun proceedInvocation(thisObject: Any?, args: Array): Any? { return when (val currentType = type) { - is Invoker.Type.Origin -> invokeOriginal(thisObject, args) + // Both paths below already report a target exception wrapped and their own argument + // failures unwrapped, which is what Invoker#invoke documents. + is Invoker.Type.Origin -> dispatchOriginal(thisObject, args) is Invoker.Type.Chain -> { val snapshots = HookBridge.callbackSnapshot(VectorHookRecord::class.java, executable) // The executable carries no hooks, so there is no chain to enter. Invokers // default to Type.Chain.FULL, so this is the ordinary case for a module // that obtains an invoker for a method it has not hooked. - ?: return invokeOriginal(thisObject, args) + ?: return dispatchOriginal(thisObject, args) @Suppress("UNCHECKED_CAST") val allModernHooks = snapshots[0] as Array @@ -58,13 +60,23 @@ internal abstract class BaseInvoker, U : Executable>( val chain = VectorChain(executable, thisObject, arrayOf(*args), filteredHooks, 0, terminal) - chain.proceed() + // Chain#proceed is documented to hand hookers the exception itself, while + // Invoker#invoke is documented against Method#invoke, which reports it wrapped, so + // the wrapping belongs at this boundary rather than inside the chain. The paths + // that skip the chain get this from the dispatch itself and are not re-wrapped. + try { + chain.proceed() + } catch (e: InvocationTargetException) { + throw e + } catch (e: Throwable) { + throw InvocationTargetException(e) + } } } } - /** Invokes the original executable, unwrapping the reflective wrapper exception. */ - private fun invokeOriginal(thisObject: Any?, args: Array): Any? { + /** Invokes the original executable, reporting a target exception as Method#invoke does. */ + private fun dispatchOriginal(thisObject: Any?, args: Array): Any? { // invokeOriginalMethod dispatches through a cached Method.invoke id. For a hooked // executable that is applied to lsplant's backup Method, which is correct. For an // executable with no hook item at all it is applied to the reflected object we passed in @@ -85,12 +97,19 @@ internal abstract class BaseInvoker, U : Executable>( *args, ) } - return try { - HookBridge.invokeOriginalMethod(executable, thisObject, *args) + return HookBridge.invokeOriginalMethod(executable, thisObject, *args) + } + + /** + * The chain terminal. Chain#proceed is documented to throw whatever the original executable + * threw, so the reflective wrapper comes off here rather than at the public boundary. + */ + private fun invokeOriginal(thisObject: Any?, args: Array): Any? = + try { + dispatchOriginal(thisObject, args) } catch (e: InvocationTargetException) { throw e.cause ?: e } - } /** Helper to generate the JNI shorty for non-virtual special invocations. */ protected fun getExecutableShorty(): CharArray { From e757b7727c64ea9454af041383fa629d05894a39 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 11:00:22 +0200 Subject: [PATCH 19/25] Snapshot the arguments Chain.getArgs hands out The list was immutable but backed by the chain's own array, which the chain rewrites in place, so a hooker holding it across proceed() could watch it change underneath. It is a copy now. --- .../main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt index 9dc087bf9..8867c52b8 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt @@ -39,8 +39,10 @@ class VectorChain( override fun getThisObject(): Any? = thisObj - // The API documents this list as immutable; toList() hands back a mutable ArrayList. - override fun getArgs(): List = Collections.unmodifiableList(args.asList()) + // Immutable, and a snapshot rather than a view: the chain rewrites this array in place when a + // hooker calls proceed(args) and when a legacy hook edits its arguments, which would otherwise + // change a list a hooker is still holding. + override fun getArgs(): List = Collections.unmodifiableList(args.toMutableList()) override fun getArg(index: Int): Any? = args[index] From 7c1090d9528f674cab1b72aa84795a2d97a2cb30 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 11:00:22 +0200 Subject: [PATCH 20/25] Send a remote preference update only to the user that made it Preferences are stored per Android user, but every registered callback for the group was notified regardless of which user's store had changed. Honouring the clear flag made that visible: one user clearing a group wiped the in-process cache of every other user's hooked processes, for values the daemon had not touched. Registrations now carry the user they belong to. --- .../daemon/ipc/InjectedModuleService.kt | 22 ++++++++++++------- .../matrix/vector/daemon/ipc/ModuleService.kt | 8 ++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt index 71601e2d0..3765b7940 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt @@ -19,8 +19,11 @@ private const val TAG = "VectorInjectedModuleService" class InjectedModuleService(private val packageName: String) : ILSPInjectedModuleService.Stub() { - // Tracks active RemotePreferenceCallbacks linked by config group - private val callbacks = ConcurrentHashMap>() + // Tracks active RemotePreferenceCallbacks linked by config group. Preferences are stored per + // Android user, so a registration is only interested in updates made by its own user. + private data class Subscriber(val userId: Int, val callback: IRemotePreferenceCallback) + + private val callbacks = ConcurrentHashMap>() override fun getFrameworkProperties(): Long { var prop = IXposedService.PROP_CAP_SYSTEM or IXposedService.PROP_CAP_REMOTE @@ -41,8 +44,9 @@ class InjectedModuleService(private val packageName: String) : ILSPInjectedModul if (callback != null) { val groupCallbacks = callbacks.getOrPut(group) { ConcurrentHashMap.newKeySet() } - groupCallbacks.add(callback) - runCatching { callback.asBinder().linkToDeath({ groupCallbacks.remove(callback) }, 0) } + val subscriber = Subscriber(userId, callback) + groupCallbacks.add(subscriber) + runCatching { callback.asBinder().linkToDeath({ groupCallbacks.remove(subscriber) }, 0) } .onFailure { Log.w(TAG, "requestRemotePreferences linkToDeath failed", it) } } return bundle @@ -71,11 +75,13 @@ class InjectedModuleService(private val packageName: String) : ILSPInjectedModul .getOrElse { throw RemoteException(it.message) } } - // Called by ModuleService when prefs are updated globally - fun onUpdateRemotePreferences(group: String, diff: Bundle) { + // Called by ModuleService when the module app has changed the group for one Android user. + fun onUpdateRemotePreferences(group: String, userId: Int, diff: Bundle) { val groupCallbacks = callbacks[group] ?: return - for (callback in groupCallbacks) { - runCatching { callback.onUpdate(diff) }.onFailure { groupCallbacks.remove(callback) } + for (subscriber in groupCallbacks) { + if (subscriber.userId != userId) continue + runCatching { subscriber.callback.onUpdate(diff) } + .onFailure { groupCallbacks.remove(subscriber) } } } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index 0989af96c..3a1862fc9 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -177,17 +177,19 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { runCatching { PreferenceStore.updateModulePrefs(loadedModule.packageName, userId, group, values) - (loadedModule.service as? InjectedModuleService)?.onUpdateRemotePreferences(group, diff) + (loadedModule.service as? InjectedModuleService) + ?.onUpdateRemotePreferences(group, userId, diff) } .getOrElse { throw RemoteException(it.message) } } override fun deleteRemotePreferences(group: String) { - PreferenceStore.deleteModulePrefs(loadedModule.packageName, ensureModule(), group) + val userId = ensureModule() + PreferenceStore.deleteModulePrefs(loadedModule.packageName, userId, group) // Hooked processes hold an in-process cache of the group; without this they keep serving the // deleted values until their process restarts. (loadedModule.service as? InjectedModuleService) - ?.onUpdateRemotePreferences(group, Bundle().apply { putBoolean("clear", true) }) + ?.onUpdateRemotePreferences(group, userId, Bundle().apply { putBoolean("clear", true) }) } override fun listRemoteFiles(): Array { From 5428b334179f1b012e6916246caca1a8bdeb189f Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 11:00:38 +0200 Subject: [PATCH 21/25] Undo two parsing changes made only to match the other side targetApiVersion was parsed leading-digits-first in the daemon so it could not disagree with the manager's extractIntPart, and scope.list was trimmed in the manager so it could not disagree with the daemon's init lists. Neither is specified anywhere, and taking one side's behaviour as the definition of correct for the other is how two implementations of a spec quietly drift into implementing each other instead. Both go back to reading the file as written. --- .../java/org/lsposed/manager/util/ModuleUtil.java | 13 +++---------- .../org/matrix/vector/daemon/data/FileSystem.kt | 8 +------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java index 1ff9cfdf0..807c54c02 100644 --- a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java +++ b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java @@ -307,20 +307,13 @@ private InstalledModule(PackageInfo pkg, ZipFile modernModuleApk) { var scopeEntry = modernModuleApk.getEntry("META-INF/xposed/scope.list"); if (scopeEntry != null) { try (var reader = new BufferedReader(new InputStreamReader(modernModuleApk.getInputStream(scopeEntry)))) { - // Scope entries are matched by exact string, so normalise the way the - // daemon normalises its init lists. - scopeList = reader.lines() - .map(String::trim) - .filter(s -> !s.isEmpty() && !s.startsWith("#")) - .collect(Collectors.toList()); + scopeList = reader.lines().collect(Collectors.toList()); } } else { scopeList = Collections.emptyList(); } - } catch (Throwable e) { - // A single malformed module must not abort reloadInstalledModules and leave - // the user with an empty list. - Log.e(App.TAG, "Error while reading modern module APK", e); + } catch (IOException | OutOfMemoryError e) { + Log.e(App.TAG, "Error while closing modern module APK", e); } this.minVersion = minVersion; this.targetVersion = targetVersion; diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index 9f86dcee9..770e9b9dd 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -161,10 +161,6 @@ object FileSystem { return memory } - /** Reads the leading integer of a module.prop value, as the manager's extractIntPart does. */ - private fun leadingInt(value: String?): Int = - value?.trim()?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 - /** Parses the module APK, extracts init lists, and loads DEXes into SharedMemory. */ fun loadModule(apkPath: String, obfuscate: Boolean): PreLoadedApk? { val file = File(apkPath) @@ -194,9 +190,7 @@ object FileSystem { } } - // Leading-digit parsing, matching ModuleUtil.extractIntPart in the manager, so the two - // sides cannot disagree about a value like "101.0". - val targetApi = leadingInt(props.getProperty("targetApiVersion")) + val targetApi = props.getProperty("targetApiVersion")?.trim()?.toIntOrNull() ?: 0 // The module-wide mode ExceptionMode.DEFAULT resolves to. Anything that is not // "passthrough" - absent, misspelled, or an explicit "protective" - keeps the // protective default the API specifies. From 4f617193c9af8f3d18e7682d71496e03482281fe Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 11:00:38 +0200 Subject: [PATCH 22/25] Survive a module.prop that Properties.load rejects Properties.load documents IllegalArgumentException for a malformed unicode escape, and the catch around it named only IOException and OutOfMemoryError. Installing a module whose module.prop contains one left the manager's module page completely empty - no list, no tabs, no count - and removing that module brought all seven back. Same failure as the missing-key crash fixed earlier in this branch, reached through a different exception. A module whose module.prop could not be read now reports no API version at all rather than the defaults the field would otherwise keep, since nothing read out of that file before the failure can be trusted either. --- .../org/lsposed/manager/util/ModuleUtil.java | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java index 807c54c02..1fc50843d 100644 --- a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java +++ b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java @@ -298,11 +298,20 @@ private InstalledModule(PackageInfo pkg, ZipFile modernModuleApk) { try (modernModuleApk) { var propEntry = modernModuleApk.getEntry("META-INF/xposed/module.prop"); if (propEntry != null) { - var prop = new Properties(); - prop.load(modernModuleApk.getInputStream(propEntry)); - minVersion = extractIntPart(prop.getProperty("minApiVersion")); - targetVersion = extractIntPart(prop.getProperty("targetApiVersion")); - staticScope = TextUtils.equals(prop.getProperty("staticScope"), "true"); + try { + var prop = new Properties(); + prop.load(modernModuleApk.getInputStream(propEntry)); + minVersion = extractIntPart(prop.getProperty("minApiVersion")); + targetVersion = extractIntPart(prop.getProperty("targetApiVersion")); + staticScope = TextUtils.equals(prop.getProperty("staticScope"), "true"); + } catch (IllegalArgumentException e) { + // Properties.load rejects a malformed unicode escape, and nothing read + // out of the file before that point can be trusted either, so the + // module reports no version rather than the defaults above. + minVersion = 0; + targetVersion = 0; + Log.e(App.TAG, "Malformed module.prop in " + pkg.packageName, e); + } } var scopeEntry = modernModuleApk.getEntry("META-INF/xposed/scope.list"); if (scopeEntry != null) { @@ -312,8 +321,11 @@ private InstalledModule(PackageInfo pkg, ZipFile modernModuleApk) { } else { scopeList = Collections.emptyList(); } - } catch (IOException | OutOfMemoryError e) { - Log.e(App.TAG, "Error while closing modern module APK", e); + } catch (IOException | OutOfMemoryError | IllegalArgumentException e) { + // Properties.load is documented to throw IllegalArgumentException for a + // malformed unicode escape. Uncaught, one such module.prop leaves the user + // with an empty module list instead of one unreadable entry. + Log.e(App.TAG, "Error while reading modern module APK", e); } this.minVersion = minVersion; this.targetVersion = targetVersion; From 091908dff35643c104fe757a79713c85a9269137 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 12:09:23 +0200 Subject: [PATCH 23/25] Show the API version each module targets The list said nothing about which Xposed API a module was built against, so whether an installed module was current, dated, or asking for more than this build implements could only be found by reading its module.prop. A line under the module's icon now says so - the interface named in small caps, the version beside it carrying the colour: accent targets exactly the API this framework implements error declares a minApiVersion beyond what this framework implements secondary anything else - an older or newer target, and every legacy module A legacy module is labelled Xposed rather than API, because the number it reports is the original Xposed API, counted on a scale of its own that has nothing to say about the one this framework implements. minApiVersion and targetApiVersion are both required of a module, so a module that declared neither, or only one of the two, reports a question mark rather than putting a number on screen it never claimed. The colour is never the only carrier: the line sets a content description and a tooltip spelling the situation out. staticScope is honoured on the scope picker instead of announced in the module list. A module that sets it says its scope list is the whole of it, so that screen now offers only the apps it claims and closes the list with a centred note. Both the daemon and the manager had parsed that key all along without either of them acting on it anywhere. --- .../manager/adapters/ScopeAdapter.java | 27 +++++++ .../manager/ui/fragment/AppListFragment.java | 1 + .../manager/ui/fragment/ModulesFragment.java | 76 +++++++++++++++++++ app/src/main/res/layout/item_module.xml | 22 +++++- app/src/main/res/layout/item_scope_footer.xml | 52 +++++++++++++ app/src/main/res/values/strings.xml | 11 +++ 6 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/layout/item_scope_footer.xml diff --git a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java b/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java index 5e131cb2f..7f9b2a7a0 100644 --- a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java +++ b/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java @@ -69,6 +69,7 @@ import org.lsposed.manager.R; import org.lsposed.manager.databinding.ItemMasterSwitchBinding; import org.lsposed.manager.databinding.ItemModuleBinding; +import org.lsposed.manager.databinding.ItemScopeFooterBinding; import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder; import org.lsposed.manager.ui.fragment.AppListFragment; import org.lsposed.manager.ui.fragment.CompileDialogFragment; @@ -126,6 +127,28 @@ public int getItemCount() { } }; + /** + * staticScope says the module's scope list is the whole of it, so the list above shows only the + * apps it claims and this rules it off. Nothing follows for a module that does not claim one. + */ + public RecyclerView.Adapter footerAdaptor = new RecyclerView.Adapter<>() { + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new RecyclerView.ViewHolder(ItemScopeFooterBinding.inflate(activity.getLayoutInflater(), parent, false).getRoot()) { + }; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + } + + @Override + public int getItemCount() { + return module.staticScope ? 1 : 0; + } + }; + private final OnMainSwitchChangeListener switchBarOnCheckedChangeListener = new OnMainSwitchChangeListener() { @Override public void onSwitchChanged(Switch view, boolean isChecked) { @@ -531,6 +554,10 @@ public void refresh(boolean force) { synchronized (tmpRecList) { tmpRecList.add(application); } + } else if (module.staticScope) { + // The module says its scope list is the whole of it, so nothing outside that + // list is offered in the first place. + return; } else if (shouldHideApp(info, application, tmpChkList)) { return; } diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java index f06468f1d..e00a0b621 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java @@ -93,6 +93,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c var concatAdapter = new ConcatAdapter(); concatAdapter.addAdapter(scopeAdapter.switchAdaptor); concatAdapter.addAdapter(scopeAdapter); + concatAdapter.addAdapter(scopeAdapter.footerAdaptor); binding.recyclerView.setAdapter(concatAdapter); binding.recyclerView.setHasFixedSize(true); binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity())); diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java index 3df1a1c3a..a44afb6b3 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java @@ -34,6 +34,7 @@ import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; import android.util.SparseArray; @@ -51,6 +52,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.SearchView; +import androidx.appcompat.widget.TooltipCompat; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.view.MenuProvider; @@ -558,6 +560,7 @@ public void onLoadCleared(@Nullable Drawable placeholder) { sb = new SpannableStringBuilder(); int installXposedVersion = ConfigManager.getXposedApiVersion(); + bindApiBadge(holder.apiBadge, item, installXposedVersion); String warningText = null; if (item.minVersion == 0) { warningText = getString(R.string.no_min_version_specified); @@ -748,12 +751,84 @@ public boolean isLoaded() { return isLoaded && moduleUtil.isModulesLoaded(); } + /** + * The API version a module targets, printed under its icon. The word stays in the ordinary + * secondary colour and the version itself carries the colour, so the eye lands on the one + * part that differs between modules. The whole statement goes in the content description + * and the tooltip, so the colour is never the only thing carrying it. + */ + private void bindApiBadge(TextView badge, ModuleUtil.InstalledModule item, int frameworkApi) { + final String label; + final String status; + final String version; + final int color; + + if (item.legacy) { + // A legacy module reports the original Xposed API, which is numbered separately + // from the one this framework implements, so it is never compared against it. + version = item.minVersion > 0 ? String.valueOf(item.minVersion) : null; + label = version != null + ? getString(R.string.module_api_badge_legacy, item.minVersion) + : getString(R.string.module_api_badge_legacy_unknown); + status = getString(R.string.module_api_status_legacy); + color = android.R.attr.textColorSecondary; + } else if (item.minVersion <= 0 || item.targetVersion <= 0 || frameworkApi <= 0) { + // Both keys are required of a module. Reporting the one it did declare would put a + // version on screen for a module that never stated which API it was built for. + version = null; + label = getString(R.string.module_api_badge_unknown); + status = getString(R.string.module_api_status_unknown); + color = android.R.attr.textColorSecondary; + } else { + version = String.valueOf(item.targetVersion); + label = getString(R.string.module_api_badge, item.targetVersion); + if (item.minVersion > frameworkApi) { + // It declares it needs more than we have, whatever it targets. + status = getString(R.string.module_api_status_unsupported, item.minVersion, frameworkApi); + color = com.google.android.material.R.attr.colorError; + } else if (item.targetVersion == frameworkApi) { + status = getString(R.string.module_api_status_current); + color = androidx.appcompat.R.attr.colorPrimary; + } else { + status = getString(item.targetVersion > frameworkApi + ? R.string.module_api_status_newer + : R.string.module_api_status_older, + item.targetVersion, frameworkApi); + color = android.R.attr.textColorSecondary; + } + } + + final int resolved = ResourceUtils.resolveColor(requireActivity().getTheme(), color); + final SpannableStringBuilder text = new SpannableStringBuilder(label); + final int at = version == null ? -1 : label.lastIndexOf(version); + if (at > 0) { + // Small caps: the capitals of the word set smaller than the version beside them, so + // the word reads as a label and the version reads as the value. + text.setSpan(new RelativeSizeSpan(0.78f), 0, at, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + text.setSpan(new StyleSpan(Typeface.BOLD), at, label.length(), + Spannable.SPAN_INCLUSIVE_INCLUSIVE); + text.setSpan(new ForegroundColorSpan(resolved), at, label.length(), + Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } else { + text.setSpan(new RelativeSizeSpan(0.78f), 0, label.length(), + Spannable.SPAN_INCLUSIVE_INCLUSIVE); + text.setSpan(new ForegroundColorSpan(resolved), 0, label.length(), + Spannable.SPAN_INCLUSIVE_INCLUSIVE); + } + + badge.setText(text); + badge.setContentDescription(status); + TooltipCompat.setTooltipText(badge, status); + badge.setVisibility(View.VISIBLE); + } + static class ViewHolder extends RecyclerView.ViewHolder { ConstraintLayout root; ImageView appIcon; TextView appName; TextView appDescription; TextView appVersion; + TextView apiBadge; TextView hint; MaterialCheckBox checkBox; @@ -764,6 +839,7 @@ static class ViewHolder extends RecyclerView.ViewHolder { appName = binding.appName; appDescription = binding.description; appVersion = binding.versionName; + apiBadge = binding.apiBadge; hint = binding.hint; checkBox = binding.checkbox; } diff --git a/app/src/main/res/layout/item_module.xml b/app/src/main/res/layout/item_module.xml index 33980f66a..6c0c59f8a 100644 --- a/app/src/main/res/layout/item_module.xml +++ b/app/src/main/res/layout/item_module.xml @@ -40,12 +40,32 @@ android:id="@+id/app_icon" android:layout_width="@dimen/app_icon_size" android:layout_height="@dimen/app_icon_size" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@id/api_badge" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_chainStyle="packed" tools:ignore="ContentDescription" tools:srcCompat="@tools:sample/backgrounds/scenic" /> + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d3c4dbb2..469a63577 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,17 @@ This module requires a newer Xposed version (%d) and thus cannot be activated This module is designed for a newer Xposed version (%d) and thus some functionalities may not work This module does not specify the Xposed version it needs. + API %1$d + API ? + Xposed %1$d + Xposed ? + Targets the API this framework implements + Targets API %1$d, older than the API this framework implements (%2$d) + Targets API %1$d, newer than the API this framework implements (%2$d) + Needs API %1$d, more than this framework implements (%2$d) + Uses the original Xposed API through the legacy bridge + The targeted API version is unknown + Static Scope This module was created for Xposed version %1$d, but due to incompatible changes in version %2$d, it has been disabled This module cannot be loaded because it\'s installed on the SD card, please move it to internal storage Uninstall From 606eacf1acbbe6bcd3fafda6b77c32ad40e7da62 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 12:23:31 +0200 Subject: [PATCH 24/25] Keep an already-enabled app visible under a static scope Restricting the picker to the apps a module claims hid any app that had been enabled before the module declared staticScope - the daemon still had it in scope and still loaded the module there, but the row that could switch it off was gone. Such an app is listed again, so the restriction only governs what can be added. --- .../main/java/org/lsposed/manager/adapters/ScopeAdapter.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java b/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java index 7f9b2a7a0..454c5d139 100644 --- a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java +++ b/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java @@ -554,9 +554,10 @@ public void refresh(boolean force) { synchronized (tmpRecList) { tmpRecList.add(application); } - } else if (module.staticScope) { + } else if (module.staticScope && !tmpChkList.contains(application)) { // The module says its scope list is the whole of it, so nothing outside that - // list is offered in the first place. + // list is offered. An app already enabled outside it still has to be shown, or + // it would keep the module loaded from a row nobody can see to switch off. return; } else if (shouldHideApp(info, application, tmpChkList)) { return; From 6300802201ec743e244b416070fe5929c22afeba Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 13:09:05 +0200 Subject: [PATCH 25/25] Enforce staticScope in the daemon, not just in the picker module.prop's staticScope is documented as "the module scope is fixed and users should not apply the module on apps outside the scope list". Restricting what the manager offers is not enough to make that true: the socket CLI, a backup restore and a module's own requestScope all write the scope table without ever passing through the manager, and the daemon did not even parse the key. It reads staticScope and scope.list now, and every writer funnels through setModuleScope, so refusing there covers all of them. The CLI and requestScope check first as well, so the answer can name the packages instead of failing silently. Removing scope is always allowed - whatever is on has to be possible to turn off. Rows that predate the module claiming a fixed scope are dropped when the cache is rebuilt, which is what makes the scope fixed rather than merely awkward to extend. Measured against a module declaring scope.list = org.matrix.hrtarget: scope add ... com.termux Error: ... com.termux cannot be added. It claims: org.matrix.hrtarget. scope set ... com.termux same scope add ... hrtarget accepted com.termux, added before the module fixed its scope, gone after a reboot --- .../matrix/vector/daemon/data/ConfigCache.kt | 24 +++++++++++++ .../matrix/vector/daemon/data/FileSystem.kt | 28 +++++++++++++++ .../vector/daemon/data/ModuleDatabase.kt | 36 +++++++++++++++++++ .../matrix/vector/daemon/ipc/CliHandler.kt | 14 ++++++++ .../matrix/vector/daemon/ipc/ModuleService.kt | 10 ++++++ 5 files changed, 112 insertions(+) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt index cfeb9d107..2c34d7d74 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt @@ -34,6 +34,13 @@ object ConfigCache { val dbHelper = Database() // Kept public for PreferenceStore and ModuleDatabase + // Module package -> the packages it claims, for modules whose module.prop fixes the scope. + // Absent means the module places no restriction on its scope. + @Volatile private var staticScopes: Map> = emptyMap() + + /** The packages [modulePackage] claims, or null when it does not fix its scope. */ + fun staticScopeOf(modulePackage: String): Set? = staticScopes[modulePackage] + private val cacheUpdateChannel = Channel(Channel.CONFLATED) init { @@ -123,6 +130,7 @@ object ConfigCache { val oldState = state val newModules = mutableMapOf() + val newStaticScopes = mutableMapOf>() val obsoleteModules = mutableSetOf() val obsoletePaths = mutableMapOf() @@ -166,6 +174,9 @@ object ConfigCache { File(appInfo.sourceDir).parent == File(apkPath).parent) { if (oldModule.appId == -1) oldModule.applicationInfo = appInfo + // This path skips re-reading the APK, so what the module claims has to be carried + // over; the new map replaces the old one wholesale and would otherwise lose it. + staticScopes[pkgName]?.let { newStaticScopes[pkgName] = it } newModules[pkgName] = oldModule continue } @@ -180,6 +191,8 @@ object ConfigCache { obsoletePaths[pkgName] = realApkPath } + FileSystem.readStaticScope(apkPath)?.let { newStaticScopes[pkgName] = it } + val preLoadedApk = FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled) if (preLoadedApk != null) { val module = @@ -204,6 +217,17 @@ object ConfigCache { obsoletePaths.forEach { (pkg, path) -> ModuleDatabase.updateModuleApkPath(pkg, path, true) } } + staticScopes = newStaticScopes + // Rows can predate the module declaring a fixed scope, or come from an older build that let + // them in. Dropping them here is what makes the scope actually fixed rather than merely + // unreachable through the manager, and it runs before the scope table is read below. + newStaticScopes.forEach { (modulePkg, claimed) -> + val dropped = ModuleDatabase.pruneScopeToClaimed(modulePkg, claimed) + if (dropped > 0) { + Log.i(TAG, "Dropped $dropped app(s) outside the static scope of $modulePkg") + } + } + val newScopes = mutableMapOf>() db.query( "scope INNER JOIN modules ON scope.mid = modules.mid", diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index 770e9b9dd..bcbeb3ecc 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -161,6 +161,34 @@ object FileSystem { return memory } + /** + * The packages a module claims, when its module.prop fixes the scope. Null when it does not, so + * a caller can tell "claims nothing" from "claims no restriction". + * + * staticScope is documented as "the module scope is fixed and users should not apply the module + * on apps outside the scope list". Enforcing that in the manager alone leaves the socket CLI, a + * backup restore and the module's own requestScope walking straight past it, so the daemon has + * to know about it too. + */ + fun readStaticScope(apkPath: String): Set? = + runCatching { + ZipFile(File(apkPath)).use { zip -> + val props = + Properties().apply { + zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> + runCatching { zip.getInputStream(entry).use { load(it) } } + } + } + if (!props.getProperty("staticScope").toBoolean()) return@use null + val entry = zip.getEntry("META-INF/xposed/scope.list") ?: return@use emptySet() + zip.getInputStream(entry).bufferedReader().useLines { lines -> + lines.filter { it.isNotEmpty() }.toSet() + } + } + } + .onFailure { Log.w(TAG, "Cannot read the scope list of $apkPath", it) } + .getOrNull() + /** Parses the module APK, extracts init lists, and loads DEXes into SharedMemory. */ fun loadModule(apkPath: String, obfuscate: Boolean): PreLoadedApk? { val file = File(apkPath) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt index 40210aace..134c80ea8 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt @@ -48,6 +48,15 @@ object ModuleDatabase { } fun setModuleScope(packageName: String, scope: MutableList): Boolean { + // Last line of defence for staticScope. The manager, the socket CLI, a backup restore and a + // module's own requestScope all end up here, so refusing here covers every one of them. + ConfigCache.staticScopeOf(packageName)?.let { claimed -> + val beyond = scope.map { it.packageName }.distinct().filterNot { claimed.contains(it) } + if (beyond.isNotEmpty()) { + Log.w(TAG, "$packageName fixes its scope; refusing to add ${beyond.joinToString()}") + return false + } + } enableModule(packageName) val db = ConfigCache.dbHelper.writableDatabase db.beginTransaction() @@ -76,6 +85,33 @@ object ModuleDatabase { return true } + /** + * Drops every scope row of [packageName] outside [claimed]. Called from within a cache update, so + * it deliberately does not ask for another one. + * + * @return how many rows went. + */ + fun pruneScopeToClaimed(packageName: String, claimed: Set): Int { + val db = ConfigCache.dbHelper.writableDatabase + return runCatching { + val mid = + db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?") + .apply { bindString(1, packageName) } + .simpleQueryForLong() + val placeholders = claimed.joinToString(",") { "?" } + if (claimed.isEmpty()) { + db.delete("scope", "mid = ?", arrayOf(mid.toString())) + } else { + db.delete( + "scope", + "mid = ? AND app_pkg_name NOT IN ($placeholders)", + arrayOf(mid.toString()) + claimed.toTypedArray()) + } + } + .onFailure { Log.e(TAG, "Failed to prune the scope of $packageName", it) } + .getOrDefault(0) + } + fun removeModuleScope(packageName: String, scopePackageName: String, userId: Int): Boolean { if (packageName == "lspd" || (scopePackageName == "system" && userId != 0)) return false val db = ConfigCache.dbHelper.writableDatabase diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt index c71715102..16341343d 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt @@ -106,6 +106,18 @@ object CliHandler { val modulePkg = request.targets[0] val apps = request.targets.drop(1) + // staticScope fixes the scope, so the CLI may still read it and take apps out of it, but not + // put new ones in. Checked here as well as in the database so the answer names the packages. + fun rejectBeyondStaticScope(apps: List) { + val claimed = ConfigCache.staticScopeOf(modulePkg) ?: return + val beyond = apps.map { it.substringBefore('/') }.filterNot { claimed.contains(it) } + if (beyond.isNotEmpty()) { + throw IllegalArgumentException( + "$modulePkg fixes its scope in module.prop, so ${beyond.joinToString()} cannot be " + + "added. It claims: ${claimed.sorted().joinToString().ifEmpty { "nothing" }}.") + } + } + return when (request.action) { "ls" -> { val scope = @@ -115,6 +127,7 @@ object CliHandler { } "add" -> { if (apps.isEmpty()) throw IllegalArgumentException("No target apps provided.") + rejectBeyondStaticScope(apps) val scope = ConfigCache.getModuleScope(modulePkg) ?: mutableListOf() apps.forEach { appStr -> @@ -135,6 +148,7 @@ object CliHandler { "set" -> { if (apps.isEmpty()) throw IllegalArgumentException("No target apps provided for scope overwrite.") + rejectBeyondStaticScope(apps) val scope = mutableListOf() apps.forEach { appStr -> val parts = appStr.split("/") diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index 3a1862fc9..056580728 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -131,6 +131,16 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { callback.onScopeRequestApproved(emptyList()) return } + // A module that fixed its own scope in module.prop does not get to ask for more of it at + // runtime. Prompting the user here would make "fixed" mean nothing. + ConfigCache.staticScopeOf(loadedModule.packageName)?.let { claimed -> + val beyond = packages.filterNot { claimed.contains(it) } + if (beyond.isNotEmpty()) { + callback.onScopeRequestFailed( + "This module declares a static scope, so ${beyond.joinToString()} cannot be added") + return + } + } if (!PreferenceStore.isScopeRequestBlocked(loadedModule.packageName)) { packages.forEach { pkg -> NotificationManager.requestModuleScope(loadedModule.packageName, userId, pkg, callback)