From 857281156ce9a8c08ab8ff4cbdec099dd4bc2de2 Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 16:10:05 +0800 Subject: [PATCH 1/8] Adopt libxposed API 102 surface: detach, hook ids and atomic replacement Bumps both libxposed submodules to 102.0.0 and implements the members the new API makes abstract. attachFramework() now takes the detach implementation, so detach() is wired to VectorLifecycleManager. activeModules is the framework's only strong reference to entry instances, which is what the javadoc requires detach() to drop; any dispatch added later must iterate it rather than keep an entry list of its own. HookHandle.replaceHook() and HookBuilder.setId() are implemented without touching the native layer. Installing the new record before uninstalling the old one would leave both in the callback multimap, so a call that snapshots in that window would run both hookers. Instead the record stays registered and its hooker is swapped through a volatile write: the native side indexes records by object identity and keeps priority in the map key, and replaceHook is specified to preserve executable, priority, exception mode and id, so nothing native-visible changes. Handle validity is tracked with a per-record epoch, and same-id intercept() reuses the installed record through a per-(module, executable, id) registry. Because the hooker is now mutable, VectorChain freezes the hooker list once when the root chain is built instead of reading it per node, so a mid-call replacement cannot leak into an in-flight call as the chain is documented to be snapshot based. libxposed-annotation is consumed as the published Maven artifact, which is what both submodules' own build files use, rather than being reimplemented in-tree. --- gradle/libs.versions.toml | 1 + services/daemon-service/build.gradle.kts | 1 + services/libxposed | 2 +- xposed/build.gradle.kts | 1 + xposed/libxposed | 2 +- .../org/matrix/vector/impl/VectorContext.kt | 4 +- .../vector/impl/VectorLifecycleManager.kt | 15 ++++ .../vector/impl/core/VectorModuleManager.kt | 7 +- .../matrix/vector/impl/hooks/VectorChain.kt | 68 ++++++++++++++--- .../vector/impl/hooks/VectorNativeHooker.kt | 75 +++++++++++++++++-- 10 files changed, 153 insertions(+), 23 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c4c6ef37..a0f1a0003 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ appiconloader = { module = "me.zhanghai.android.appiconloader:appiconloader", ve material = { module = "com.google.android.material:material", version = "1.12.0" } gson = { module = "com.google.code.gson:gson", version = "2.14.0" } hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "6.1" } +libxposed-annotation = { module = "io.github.libxposed:annotation", version = "1.0.0" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/services/daemon-service/build.gradle.kts b/services/daemon-service/build.gradle.kts index e583a2570..7062e6ded 100644 --- a/services/daemon-service/build.gradle.kts +++ b/services/daemon-service/build.gradle.kts @@ -18,5 +18,6 @@ android { dependencies { compileOnly(libs.androidx.annotation) + compileOnly(libs.libxposed.annotation) compileOnly(projects.hiddenapi.stubs) } diff --git a/services/libxposed b/services/libxposed index 11f8945de..331894087 160000 --- a/services/libxposed +++ b/services/libxposed @@ -1 +1 @@ -Subproject commit 11f8945de4e24efc0eb0e2e87a2dd8284d8f7b66 +Subproject commit 3318940876192e29cf6ab07637e899e22a87ebf0 diff --git a/xposed/build.gradle.kts b/xposed/build.gradle.kts index ef5cee53c..ad1afb277 100644 --- a/xposed/build.gradle.kts +++ b/xposed/build.gradle.kts @@ -28,5 +28,6 @@ dependencies { implementation(projects.hiddenapi.bridge) implementation(projects.services.daemonService) compileOnly(libs.androidx.annotation) + compileOnly(libs.libxposed.annotation) compileOnly(projects.hiddenapi.stubs) } diff --git a/xposed/libxposed b/xposed/libxposed index edeb8379c..45e7c5cfe 160000 --- a/xposed/libxposed +++ b/xposed/libxposed @@ -1 +1 @@ -Subproject commit edeb8379c067b16b91af3cb526f5f04db25c06b6 +Subproject commit 45e7c5cfe54725b6d828d8b7be65e22ce60c67e4 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..b3b2de1c6 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -40,14 +40,14 @@ class VectorContext( } override fun hook(origin: Executable): XposedInterface.HookBuilder { - return VectorHookBuilder(origin) + return VectorHookBuilder(origin, packageName) } 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, packageName) } override fun deoptimize(executable: Executable): Boolean { diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt index e1d73ac92..19ce25d46 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt @@ -13,8 +13,23 @@ object VectorLifecycleManager { private const val TAG = "VectorLifecycle" + /** + * The framework's only strong reference to module entry instances. [detach] is specified to + * remove that reference and to stop *every* lifecycle callback, so any dispatch added later + * (including hot reload) must iterate this set rather than keep an entry list of its own. + */ val activeModules: MutableSet = ConcurrentHashMap.newKeySet() + /** + * Backs `XposedInterfaceWrapper.detach()` for a single entry instance. Idempotent, as the API + * requires. + */ + fun detach(module: XposedModule) { + if (activeModules.remove(module)) { + Log.d(TAG, "Detached entry ${module.javaClass.name}") + } + } + fun dispatchPackageLoaded( packageName: String, appInfo: ApplicationInfo, 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..65ae026a7 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 @@ -79,8 +79,11 @@ object VectorModuleManager { constructor.isAccessible = true val moduleInstance = constructor.newInstance() as XposedModule - // Attach the framework context to the module - moduleInstance.attachFramework(vectorContext) + // Attach the framework context to the module. The detach implementation is + // per entry: only the instance that calls detach() stops receiving events. + moduleInstance.attachFramework(vectorContext) { + VectorLifecycleManager.detach(moduleInstance) + } // Register the active module to receive future lifecycle events VectorLifecycleManager.activeModules.add(moduleInstance) 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..36d659603 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 @@ -1,31 +1,65 @@ package org.matrix.vector.impl.hooks -import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedInterface.Chain import io.github.libxposed.api.XposedInterface.ExceptionMode +import io.github.libxposed.api.XposedInterface.Hooker import java.lang.reflect.Executable +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import org.lsposed.lspd.util.Utils /** Represents a registered hook configuration, stored natively by [HookBridge]. */ -data class VectorHookRecord( - val hooker: XposedInterface.Hooker, +class VectorHookRecord( + // Mutable so a hook can be replaced in place. The native layer indexes this record by object + // identity, so swapping the hooker is invisible to it: there is always exactly one record in the + // callback map, hence no window in which two hookers both look active. + @Volatile var hooker: Hooker, val priority: Int, val exceptionMode: ExceptionMode, -) + val id: String?, +) { + // Bumped on every replacement; a hook handle stays valid only while its captured value matches. + val epoch = AtomicInteger(0) + + // Cleared on unhook to make unhook idempotent and to invalidate outstanding handles. + val installed = AtomicBoolean(true) +} /** * Core interceptor chain engine. Manages recursive hook execution and enforces [ExceptionMode] * protections. */ -class VectorChain( +class VectorChain +private constructor( private val executable: Executable, private val thisObj: Any?, private val args: Array, private val hooks: Array, + // Frozen snapshot of the hookers, captured once at the root so replacing a hooker mid-call does + // not affect this in-flight call (the chain is snapshot based). + private val hookers: Array, private val hookIndex: Int, private val terminal: (thisObj: Any?, args: Array) -> Any?, ) : Chain { + /** Entry point used to start a call; freezes the current hooker list once for the whole call. */ + constructor( + executable: Executable, + thisObj: Any?, + args: Array, + hooks: Array, + hookIndex: Int, + terminal: (thisObj: Any?, args: Array) -> Any?, + ) : this( + executable, + thisObj, + args, + hooks, + Array(hooks.size) { hooks[it].hooker }, + hookIndex, + terminal, + ) + // Tracks if this specific chain node has forwarded execution downstream internal var proceedCalled: Boolean = false private set @@ -59,14 +93,23 @@ class VectorChain( return executeDownstream { terminal(thisObject, currentArgs) } } - val record = hooks[hookIndex] + val hooker = hookers[hookIndex] + val exceptionMode = hooks[hookIndex].exceptionMode val nextChain = - VectorChain(executable, thisObject, currentArgs, hooks, hookIndex + 1, terminal) + VectorChain( + executable, + thisObject, + currentArgs, + hooks, + hookers, + hookIndex + 1, + terminal, + ) return try { - executeDownstream { record.hooker.intercept(nextChain) } + executeDownstream { hooker.intercept(nextChain) } } catch (t: Throwable) { - handleInterceptorException(t, record, nextChain, thisObject, currentArgs) + handleInterceptorException(t, hooker, exceptionMode, nextChain, thisObject, currentArgs) } } @@ -88,7 +131,8 @@ class VectorChain( /** Handles exceptions thrown by a hooker according to its [ExceptionMode]. */ private fun handleInterceptorException( t: Throwable, - record: VectorHookRecord, + hooker: Hooker, + exceptionMode: ExceptionMode, nextChain: VectorChain, recoveryThis: Any?, recoveryArgs: Array, @@ -99,11 +143,11 @@ class VectorChain( } // Passthrough mode does not rescue the process from hooker crashes - if (record.exceptionMode == ExceptionMode.PASSTHROUGH) { + if (exceptionMode == ExceptionMode.PASSTHROUGH) { throw t } - val hookerName = record.hooker.javaClass.name + val hookerName = hooker.javaClass.name if (!nextChain.proceedCalled) { // Crash occurred before calling proceed(); skip hooker and continue the chain Utils.logD("Hooker [$hookerName] crashed before proceed. Skipping.", t) 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..660c75993 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 @@ -10,15 +10,23 @@ import java.lang.reflect.Executable import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import java.lang.reflect.Modifier +import java.util.concurrent.ConcurrentHashMap import org.lsposed.lspd.util.Utils 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 { +/** + * Builder for configuring and registering hooks. + * + * [moduleId] identifies the owning module so hook ids can be isolated between modules; it is null for + * framework-internal hooks, which never use ids. + */ +class VectorHookBuilder(private val origin: Executable, private val moduleId: Any? = null) : + HookBuilder { private var priority = XposedInterface.PRIORITY_DEFAULT private var exceptionMode = ExceptionMode.DEFAULT + private var id: String? = null override fun setPriority(priority: Int): HookBuilder = apply { this.priority = priority } @@ -26,6 +34,8 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder { this.exceptionMode = mode } + override fun setId(id: String?): HookBuilder = apply { this.id = id } + override fun intercept(hooker: Hooker): HookHandle { if (Modifier.isAbstract(origin.modifiers)) { throw IllegalArgumentException("Cannot hook abstract methods: $origin") @@ -39,7 +49,30 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder { throw IllegalArgumentException("Cannot hook Method.invoke") } - val record = VectorHookRecord(hooker, priority, exceptionMode) + val id = this.id + if (id != null) { + val key = IdKey(moduleId, origin, id) + val existing = idRegistry[key] + if (existing != null && existing.installed.get()) { + // Same (module, executable, id): replace in place via the volatile-write path rather + // than installing a second native record. Bumping the epoch invalidates the old + // handle; native registration is untouched. + val epoch = existing.epoch.incrementAndGet() + existing.hooker = hooker + return handleFor(existing, epoch) + } + + val record = VectorHookRecord(hooker, priority, exceptionMode, id) + if ( + !HookBridge.hookMethod(true, origin, VectorNativeHooker::class.java, priority, record) + ) { + throw HookFailedError("Cannot hook $origin") + } + idRegistry[key] = record + return handleFor(record, record.epoch.get()) + } + + val record = VectorHookRecord(hooker, priority, exceptionMode, null) // Register natively. HookBridge now stores VectorHookRecord instead of HookerCallback. if ( @@ -48,16 +81,48 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder { throw HookFailedError("Cannot hook $origin") } - return object : HookHandle { + return handleFor(record, record.epoch.get()) + } + + /** + * Creates a handle bound to [record] and the epoch at which it became valid. The handle is stale + * once the record is replaced (epoch moves on) or unhooked. + */ + private fun handleFor(record: VectorHookRecord, epoch: Int): HookHandle = + object : HookHandle { override fun getExecutable(): Executable = origin + override fun getId(): String? = record.id + override fun unhook() { - HookBridge.unhookMethod(true, origin, record) + if (record.installed.compareAndSet(true, false)) { + HookBridge.unhookMethod(true, origin, record) + record.id?.let { idRegistry.remove(IdKey(moduleId, origin, it), record) } + } + } + + override fun replaceHook(hooker: Hooker): HookHandle { + // Valid only while still installed and no replacement happened since this handle. + // The epoch CAS also makes concurrent replacements from the same handle mutually + // exclusive. + if (!record.installed.get() || !record.epoch.compareAndSet(epoch, epoch + 1)) { + throw IllegalStateException("Hook handle is no longer valid") + } + record.hooker = hooker + return handleFor(record, epoch + 1) } } + + companion object { + // Registry of id-bearing hooks keyed by (module, executable, id). Lets a repeated intercept() + // with an already-registered id reuse the installed record. Ids are isolated per module. + private val idRegistry = ConcurrentHashMap() } } +/** Registry key scoping a hook id to its owning module and executable. */ +private data class IdKey(val moduleId: Any?, val executable: Executable, val id: String) + /** * The native callback entrypoint. Instantiated natively by [HookBridge] when a hooked method is * hit. From 096d0b37dcdaf32afbd237fb499031102c208689 Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 16:18:56 +0800 Subject: [PATCH 2/8] Claim hook ids atomically when installing a record The id path read the registry and then installed, so two threads racing on one id both saw no entry and both installed a native record. That is the duplicate-record window the in-place replacement design exists to avoid, and the later registry write also orphaned the first record, leaving it hooked with no way to reach it. Claiming the key with putIfAbsent makes the winner the only installer; everyone else takes the volatile-write path. --- .../vector/impl/hooks/VectorNativeHooker.kt | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) 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 660c75993..ce01675d2 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 @@ -52,24 +52,38 @@ class VectorHookBuilder(private val origin: Executable, private val moduleId: An val id = this.id if (id != null) { val key = IdKey(moduleId, origin, id) - val existing = idRegistry[key] - if (existing != null && existing.installed.get()) { - // Same (module, executable, id): replace in place via the volatile-write path rather - // than installing a second native record. Bumping the epoch invalidates the old - // handle; native registration is untouched. - val epoch = existing.epoch.incrementAndGet() - existing.hooker = hooker - return handleFor(existing, epoch) + // Claiming the key with putIfAbsent is the only atomic point: whoever wins installs the + // single native record, everyone else replaces its hooker in place. A check-then-act here + // would let two threads install two records for one id, which is the very duplication + // this design exists to avoid. + val candidate = VectorHookRecord(hooker, priority, exceptionMode, id) + while (true) { + val existing = idRegistry.putIfAbsent(key, candidate) ?: break + if (existing.installed.get()) { + // Same (module, executable, id): replace in place via the volatile-write path + // rather than installing a second native record. Bumping the epoch invalidates + // the old handle; native registration is untouched. + val epoch = existing.epoch.incrementAndGet() + existing.hooker = hooker + return handleFor(existing, epoch) + } + // The id is held by a record that has since been unhooked; drop it and retry. + idRegistry.remove(key, existing) } - val record = VectorHookRecord(hooker, priority, exceptionMode, id) if ( - !HookBridge.hookMethod(true, origin, VectorNativeHooker::class.java, priority, record) + !HookBridge.hookMethod( + true, + origin, + VectorNativeHooker::class.java, + priority, + candidate, + ) ) { + idRegistry.remove(key, candidate) throw HookFailedError("Cannot hook $origin") } - idRegistry[key] = record - return handleFor(record, record.epoch.get()) + return handleFor(candidate, candidate.epoch.get()) } val record = VectorHookRecord(hooker, priority, exceptionMode, null) From dd591462e6b2a118eb70136f6a88e903641d0e4a Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 16:19:10 +0800 Subject: [PATCH 3/8] Deny legacy de.robv APIs to modules targeting API 102 XposedInterface.API_102 states the behaviour change plainly: "Libxposed modules can not call legacy de.robv.android.xposed APIs." targetApiVersion only ever selected MODERN over LEGACY loading, so nothing stopped a module declaring 102 from using the legacy bridge. The module classloader is where this has to be enforced. Its parent is the framework's own loader, which carries de.robv, so refusing to resolve that package covers reflective lookups against the loader as well as direct references. targetApiVersion is carried on PreLoadedApk to reach it. module.prop parsing follows #794: Java Properties instead of a hand-rolled split("="), tolerating a malformed file rather than making the APK unloadable, and leading-digit integers matching the manager's extractIntPart. That PR is still open and owns this parsing plus an exceptionPassthrough field on the same parcelable, so expect both to conflict here and resolve in its favour. Note that :daemon does not compile yet: bumping libxposed to 102 added getRunningTargets() and hotReloadModule() to IXposedService, which the hot reload work still has to implement. --- .../matrix/vector/daemon/data/FileSystem.kt | 34 +++++++++++++------ .../org/lsposed/lspd/models/PreLoadedApk.aidl | 1 + .../vector/impl/core/VectorModuleManager.kt | 1 + .../impl/utils/VectorModuleClassLoader.kt | 33 ++++++++++++++++-- 4 files changed, 56 insertions(+), 13 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..8cffdca1a 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) @@ -170,23 +175,29 @@ object FileSystem { val moduleClassNames = mutableListOf() val moduleLibraryNames = mutableListOf() var isLegacy = false + var targetApiVersion = 0 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")) + targetApiVersion = targetApi val hasLegacyFile = zip.getEntry("assets/xposed_init") != null // Determine Loading Strategy based on Priority: API 101+ > Legacy > API 100 @@ -263,6 +274,7 @@ object FileSystem { this.moduleClassNames = moduleClassNames this.moduleLibraryNames = moduleLibraryNames this.legacy = isLegacy + this.targetApiVersion = targetApiVersion } 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..aad85b1c2 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,5 @@ parcelable PreLoadedApk { List moduleClassNames; List moduleLibraryNames; boolean legacy; + int targetApiVersion; } 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 65ae026a7..ad448da20 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 @@ -45,6 +45,7 @@ object VectorModuleManager { module.file.preLoadedDexes, librarySearchPath, initLoader, + blockLegacyApi = module.file.targetApiVersion >= 102, ) // Security/Integrity Check: Ensure the module isn't bundling its own API classes diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt index d4ec60895..70085a19f 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt @@ -26,6 +26,7 @@ import java.util.zip.ZipEntry class VectorModuleClassLoader : ByteBufferDexClassLoader { private val apkPath: String + private val blockLegacyApi: Boolean private val nativeLibraryDirs = mutableListOf() @RequiresApi(Build.VERSION_CODES.Q) @@ -34,8 +35,10 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { librarySearchPath: String?, parent: ClassLoader?, apkPath: String, + blockLegacyApi: Boolean, ) : super(dexBuffers, librarySearchPath, parent) { this.apkPath = apkPath + this.blockLegacyApi = blockLegacyApi initNativeDirs(librarySearchPath) } @@ -44,8 +47,10 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { parent: ClassLoader?, apkPath: String, librarySearchPath: String?, + blockLegacyApi: Boolean, ) : super(dexBuffers, parent) { this.apkPath = apkPath + this.blockLegacyApi = blockLegacyApi initNativeDirs(librarySearchPath) } @@ -57,6 +62,16 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { @Throws(ClassNotFoundException::class) override fun loadClass(name: String, resolve: Boolean): Class<*> { + // API 102 forbids libxposed modules from calling the legacy de.robv APIs. This loader's + // parent is the framework's own loader, which carries the legacy bridge, so refusing to + // resolve the package here is what actually enforces it - reflective lookups against this + // loader included. + if (blockLegacyApi && name.startsWith(LEGACY_API_PREFIX)) { + throw ClassNotFoundException( + "$name is unavailable to modules targeting Xposed API 102 or higher" + ) + } + findLoadedClass(name)?.let { return it } @@ -130,6 +145,7 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { companion object { private const val TAG = "VectorModuleClassLoader" private const val ZIP_SEPARATOR = "!/" + private const val LEGACY_API_PREFIX = "de.robv.android.xposed." private val SYSTEM_NATIVE_LIBRARY_DIRS = splitPaths(System.getProperty("java.library.path")) private fun splitPaths(searchPath: String?): List { @@ -148,6 +164,7 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { dexes: List, librarySearchPath: String, parent: ClassLoader?, + blockLegacyApi: Boolean = false, ): ClassLoader { val dexBuffers = dexes @@ -166,9 +183,21 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { val cl = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - VectorModuleClassLoader(dexBuffers, librarySearchPath, parent, apk) + VectorModuleClassLoader( + dexBuffers, + librarySearchPath, + parent, + apk, + blockLegacyApi, + ) } else { - VectorModuleClassLoader(dexBuffers, parent, apk, librarySearchPath) + VectorModuleClassLoader( + dexBuffers, + parent, + apk, + librarySearchPath, + blockLegacyApi, + ) } dexBuffers.toList().parallelStream().forEach { SharedMemory.unmap(it) } From 6b1609d743b10e739b5d9669d5bfb5a114edf961 Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 16:28:54 +0800 Subject: [PATCH 4/8] Derive hot reload targets from the daemon's process registry IXposedService gained getRunningTargets() and hotReloadModule() in API 102. Targets are built from the heartbeat registry ApplicationService already keeps, not from a registration call made by the injected process. That ordering matters: in system_server the module is loaded long before the daemon's config cache is populated, so a module-initiated registration cannot be answered and the process would silently never become a target - the one process where not rebooting is worth the most. getRunningTargets() reports every hooked process, including modules with zero or several Java entry classes, because the AIDL documents it as running processes hooked by this module rather than hot-reloadable ones. Those targets answer UNSUPPORTED instead of being hidden. hotReloadModule() raises SecurityException only for the two conditions the AIDL reserves it for, an unknown target id or one belonging to another module, so a SecurityException arriving from module code can still be reported as a result. Claiming a target is a compareAndSet rather than a read followed by a write, since reloads are serialized per target. The reload itself is not implemented yet and is reported as UNSUPPORTED, which package-info permits when the framework cannot provide a valid new module generation. Reporting SUCCEEDED would be untrue and FAILED would tell the module app that its own code refused. Module carries versionCode explicitly: ApplicationInfo.longVersionCode is hidden, and the system_server path builds its modules before PMS exists, so it reads the version through PackageParser to avoid reporting 0 there. --- .../matrix/vector/daemon/data/ConfigCache.kt | 4 + .../vector/daemon/ipc/ApplicationService.kt | 91 ++++++++++++++++++- .../matrix/vector/daemon/ipc/ModuleService.kt | 40 ++++++++ .../android/content/pm/PackageParser.java | 1 + .../aidl/org/lsposed/lspd/models/Module.aidl | 1 + .../impl/utils/VectorModuleClassLoader.kt | 1 + 6 files changed, 137 insertions(+), 1 deletion(-) 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..b2070f419 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 @@ -187,6 +187,7 @@ object ConfigCache { packageName = pkgName this.apkPath = apkPath appId = appInfo.uid + versionCode = pkgInfo?.longVersionCode ?: 0L applicationInfo = appInfo service = oldModule?.service ?: InjectedModuleService(pkgName) file = preLoadedApk @@ -383,6 +384,9 @@ object ConfigCache { @Suppress("DEPRECATION") val pkg = PackageParser().parsePackage(File(apkPath), 0, false) module.applicationInfo = pkg.applicationInfo + // This is the system_server path, which runs before PMS is available. Reading the + // version here keeps its hot reload targets as well described as any other. + module.versionCode = pkg.mVersionCode.toLong() } .onFailure { Log.w(TAG, "PackageParser failed for $apkPath, using fallback ApplicationInfo") diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt index fb4f817e8..0c9bfc172 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt @@ -6,7 +6,10 @@ import android.os.ParcelFileDescriptor import android.os.Process import android.os.RemoteException import android.util.Log +import io.github.libxposed.service.HookedProcess import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import org.lsposed.lspd.models.Module import org.lsposed.lspd.service.ILSPApplicationService import org.matrix.vector.daemon.data.ConfigCache @@ -30,8 +33,34 @@ object ApplicationService : ILSPApplicationService.Stub() { private val processes = ConcurrentHashMap() + /** + * One module generation loaded into one process: the unit a hot reload request addresses. Targets + * are derived from the process registry rather than registered by the injected process itself, so + * a target exists as soon as the daemon has handed the module out - including in system_server, + * whose modules are loaded long before a module-initiated registration could be answered. + */ + class HotReloadTarget( + val id: Long, + val modulePackageName: String, + val processName: String, + val uid: Int, + val pid: Int, + val loadedVersionCode: Long, + val hotReloadable: Boolean, + ) { + val state = AtomicInteger(HookedProcess.TARGET_STATE_UP_TO_DATE) + } + + private val hotReloadTargets = ConcurrentHashMap() + + // Ids are framework-assigned and never reused, as HookedProcess.targetId requires. + private val nextHotReloadTargetId = AtomicLong(1) + private class ProcessInfo(val key: ProcessKey, val processName: String, val heartBeat: IBinder) : IBinder.DeathRecipient { + /** Hot reload target ids owned by this process, keyed by module package name. */ + val targetIds = ConcurrentHashMap() + init { heartBeat.linkToDeath(this, 0) processes[key] = this @@ -40,9 +69,68 @@ object ApplicationService : ILSPApplicationService.Stub() { override fun binderDied() { heartBeat.unlinkToDeath(this, 0) processes.remove(key) + targetIds.values.forEach { hotReloadTargets.remove(it) } } } + /** Records the module generations handed to [info], assigning a target id to each. */ + private fun recordHotReloadTargets(info: ProcessInfo, modules: List) { + for (module in modules) { + info.targetIds.computeIfAbsent(module.packageName) { + val id = nextHotReloadTargetId.getAndIncrement() + hotReloadTargets[id] = + HotReloadTarget( + id = id, + modulePackageName = module.packageName, + processName = info.processName, + uid = info.key.uid, + pid = info.key.pid, + loadedVersionCode = module.versionCode, + // Hot reload is specified only for modules with exactly one Java entry class. + hotReloadable = module.file.moduleClassNames.size == 1, + ) + id + } + } + } + + /** + * All processes currently hooked by [modulePackageName]. Not filtered to hot-reloadable targets: + * getRunningTargets() is documented as returning hooked processes, and a target that cannot be + * reloaded answers UNSUPPORTED rather than being hidden. + */ + fun getHotReloadTargets(modulePackageName: String): List = + hotReloadTargets.values + .filter { it.modulePackageName == modulePackageName } + .map { target -> + HookedProcess().apply { + targetId = target.id + uid = target.uid + pid = target.pid + processName = target.processName + state = target.state.get() + loadedVersionCode = target.loadedVersionCode + } + } + + /** Resolves a target id, but only for the module that owns it. */ + fun getHotReloadTarget(targetId: Long, modulePackageName: String): HotReloadTarget? = + hotReloadTargets[targetId]?.takeIf { it.modulePackageName == modulePackageName } + + /** + * Claims [target] for a reload. Reloads are serialized per target, so the check and the transition + * have to be one atomic step rather than a read followed by a write. + */ + fun beginHotReload(target: HotReloadTarget): Boolean { + while (true) { + val current = target.state.get() + if (current == HookedProcess.TARGET_STATE_RELOADING) return false + if (target.state.compareAndSet(current, HookedProcess.TARGET_STATE_RELOADING)) return true + } + } + + fun endHotReload(target: HotReloadTarget, state: Int) = target.state.set(state) + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { when (code) { DEX_TRANSACTION_CODE -> { @@ -98,7 +186,8 @@ object ApplicationService : ILSPApplicationService.Stub() { return ConfigCache.getModulesForProcess(info.processName, info.key.uid) } - override fun getModulesList() = getAllModules().filter { !it.file.legacy } + override fun getModulesList() = + getAllModules().filter { !it.file.legacy }.also { recordHotReloadTargets(ensureRegistered(), it) } override fun getLegacyModulesList() = getAllModules().filter { it.file.legacy } 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..0ca10ee58 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 @@ -7,6 +7,8 @@ import android.os.Bundle import android.os.ParcelFileDescriptor import android.os.RemoteException import android.util.Log +import io.github.libxposed.service.HookedProcess +import io.github.libxposed.service.IHotReloadCallback import io.github.libxposed.service.IXposedScopeCallback import io.github.libxposed.service.IXposedService import java.io.Serializable @@ -140,6 +142,44 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } } + override fun getRunningTargets(): List { + ensureModule() + return ApplicationService.getHotReloadTargets(loadedModule.packageName) + } + + override fun hotReloadModule(targetId: Long, data: Bundle?, callback: IHotReloadCallback?) { + ensureModule() + // SecurityException is reserved by the AIDL for exactly these two conditions, so it must not be + // raised for anything else on this path - a module-thrown SecurityException in particular has + // to reach the caller as a FAILED result, not as "invalid target id". + val target = + ApplicationService.getHotReloadTarget(targetId, loadedModule.packageName) + ?: throw SecurityException("Target $targetId is not a target of ${loadedModule.packageName}") + + if (!target.hotReloadable) { + // Hot reload is specified only for modules declaring exactly one Java entry class. + report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "Module has no single Java entry class") + return + } + + if (!ApplicationService.beginHotReload(target)) { + report(callback, IXposedService.HOT_RELOAD_IN_PROGRESS, "A reload is already running") + return + } + + // No new generation can be staged yet, which package-info explicitly allows a framework to + // report as unsupported. Reporting SUCCEEDED here would be a lie, and FAILED would tell the + // module app its own code refused. + ApplicationService.endHotReload(target, HookedProcess.TARGET_STATE_UP_TO_DATE) + report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "Hot reload is not implemented yet") + } + + private fun report(callback: IHotReloadCallback?, status: Int, message: String?) { + // The callback is oneway, so a dead module app must not surface as a failure here. + runCatching { callback?.onHotReloadResult(status, message) } + .onFailure { Log.w(TAG, "Cannot deliver hot reload result to ${loadedModule.packageName}", it) } + } + override fun requestRemotePreferences(group: String): Bundle { val userId = ensureModule() return Bundle().apply { diff --git a/hiddenapi/stubs/src/main/java/android/content/pm/PackageParser.java b/hiddenapi/stubs/src/main/java/android/content/pm/PackageParser.java index bbdeb54b1..f9d553a5d 100644 --- a/hiddenapi/stubs/src/main/java/android/content/pm/PackageParser.java +++ b/hiddenapi/stubs/src/main/java/android/content/pm/PackageParser.java @@ -9,6 +9,7 @@ public static class PackageLite { public final static class Package { public ApplicationInfo applicationInfo; + public int mVersionCode; } /** Before SDK21 */ diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl index d2886f902..fbc7a5132 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl @@ -5,6 +5,7 @@ import org.lsposed.lspd.service.ILSPInjectedModuleService; parcelable Module { String packageName; int appId; + long versionCode; String apkPath; PreLoadedApk file; ApplicationInfo applicationInfo; diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt index 70085a19f..c6012e045 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt @@ -159,6 +159,7 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { * fully instantiated. */ @JvmStatic + @JvmOverloads fun loadApk( apk: String, dexes: List, From d94dcaa85eab461c2e39c72976ce9ec2589d9382 Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 16:49:23 +0800 Subject: [PATCH 5/8] Implement hot reload of a module generation in place The daemon reaches the hooked process through a new IHotReloadTarget, which each process registers while the framework bootstraps rather than when its modules load. Registration therefore does not depend on the daemon's module cache, which is what previously kept system_server from ever becoming a target. Ordering follows the callback contract. Old code is frozen before onHotReloading runs, not after, so hook registrations attempted from inside the callback fail while the unhook and replace paths the same javadoc tells modules to use keep working; the freeze gates the single registration entry point and keys on the generation rather than on the hooker's classloader. The old hook handle list is captured after that. The new generation becomes the committed one only after onHotReloaded returns. If it throws, the hooks the new generation installed are removed, the old entries are made live again and the old generation stays committed. Committing first would make that rollback unreachable, which is what the previous attempt did while advertising it. Result encoding keeps the two failures distinguishable, since a null message is reserved for onHotReloading returning false: every other failure carries a diagnostic, synthesized from the throwable's class when it has no message of its own. A process whose entries have all detached reports UNSUPPORTED rather than borrowing the refusal encoding for a decision no module code made. A frozen target is thawed for the transaction, because a cached process's binder is alive but unreachable and the failure would otherwise be indistinguishable from a refusal. detach() is honoured for both callbacks: dispatch resolves entries through VectorLifecycleManager.activeModules, so a detached entry is skipped and the framework holds no entry list of its own. The old generation stays strongly reachable only for the duration of the cycle. Hot reload also drops the module's hook id registrations, which strongly reference hook records and through them the retired classloader. Not verified on a device yet. The frozen-target path, the freeze, and the rollback are the parts that only surface at runtime. --- .../vector/daemon/ipc/ApplicationService.kt | 14 + .../matrix/vector/daemon/ipc/ModuleService.kt | 88 +++++- .../vector/daemon/system/ProcessFreezer.kt | 57 ++++ .../lsposed/lspd/models/HotReloadOutcome.aidl | 21 ++ .../lspd/service/IHotReloadTarget.aidl | 20 ++ .../lspd/service/ILSPApplicationService.aidl | 8 + .../org/matrix/vector/impl/VectorContext.kt | 18 +- .../vector/impl/VectorLifecycleManager.kt | 3 + .../vector/impl/core/VectorHotReloadTarget.kt | 22 ++ .../vector/impl/core/VectorModuleManager.kt | 296 ++++++++++++++++-- .../vector/impl/core/VectorServiceClient.kt | 19 ++ .../vector/impl/hooks/VectorNativeHooker.kt | 125 ++++++-- 12 files changed, 638 insertions(+), 53 deletions(-) create mode 100644 daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt create mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl create mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl create mode 100644 xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt index 0c9bfc172..c743871a9 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt @@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import org.lsposed.lspd.models.Module +import org.lsposed.lspd.service.IHotReloadTarget import org.lsposed.lspd.service.ILSPApplicationService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem @@ -61,6 +62,9 @@ object ApplicationService : ILSPApplicationService.Stub() { /** Hot reload target ids owned by this process, keyed by module package name. */ val targetIds = ConcurrentHashMap() + /** Set once while the process bootstraps; null until then, and for legacy-only processes. */ + @Volatile var hotReloadBinder: IHotReloadTarget? = null + init { heartBeat.linkToDeath(this, 0) processes[key] = this @@ -131,6 +135,16 @@ object ApplicationService : ILSPApplicationService.Stub() { fun endHotReload(target: HotReloadTarget, state: Int) = target.state.set(state) + /** The reload entry point of the process holding [target], or null if it never registered one. */ + fun getHotReloadBinder(target: HotReloadTarget): IHotReloadTarget? = + processes[ProcessKey(target.uid, target.pid)]?.hotReloadBinder + + override fun registerHotReloadTarget(target: IHotReloadTarget) { + val info = ensureRegistered() + info.hotReloadBinder = target + Log.d(TAG, "Hot reload target registered for ${info.processName} (pid=${info.key.pid})") + } + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { when (code) { DEX_TRANSACTION_CODE -> { 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 0ca10ee58..0a1c49e34 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 @@ -4,6 +4,7 @@ import android.content.AttributionSource import android.os.Binder import android.os.Build import android.os.Bundle +import android.os.DeadObjectException import android.os.ParcelFileDescriptor import android.os.RemoteException import android.util.Log @@ -15,6 +16,7 @@ import java.io.Serializable import java.util.Collections import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors import org.lsposed.lspd.models.Module import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.data.ConfigCache @@ -22,6 +24,7 @@ import org.matrix.vector.daemon.data.FileSystem import org.matrix.vector.daemon.data.ModuleDatabase import org.matrix.vector.daemon.data.PreferenceStore import org.matrix.vector.daemon.system.NotificationManager +import org.matrix.vector.daemon.system.ProcessFreezer import org.matrix.vector.daemon.system.PER_USER_RANGE import org.matrix.vector.daemon.system.activityManager @@ -30,6 +33,12 @@ private const val TAG = "VectorModuleService" class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { companion object { + // Reloads run off the binder thread that served the module app. Per-target serialization is + // enforced by the target's own state, so this pool only has to keep one slow target from + // delaying another. + private val hotReloadExecutor = + Executors.newCachedThreadPool { r -> Thread(r, "vector-hot-reload") } + private val uidSet = ConcurrentHashMap.newKeySet() private val serviceMap = Collections.synchronizedMap(WeakHashMap()) @@ -167,13 +176,82 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { return } - // No new generation can be staged yet, which package-info explicitly allows a framework to - // report as unsupported. Reporting SUCCEEDED here would be a lie, and FAILED would tell the - // module app its own code refused. - ApplicationService.endHotReload(target, HookedProcess.TARGET_STATE_UP_TO_DATE) - report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "Hot reload is not implemented yet") + // The AIDL asks implementations to validate and enqueue promptly and report through the + // callback. Running the cycle inline would pin this binder thread for its whole duration and + // ANR a module app that called from its main thread. + hotReloadExecutor.execute { runHotReload(target, data, callback) } + } + + private fun runHotReload( + target: ApplicationService.HotReloadTarget, + data: Bundle?, + callback: IHotReloadCallback?, + ) { + var status = IXposedService.HOT_RELOAD_FAILED + var message: String? = "Hot reload did not run" + var refreeze: (() -> Unit)? = null + + try { + val binder = ApplicationService.getHotReloadBinder(target) + if (binder == null) { + status = IXposedService.HOT_RELOAD_UNSUPPORTED + message = "Process ${target.processName} has no hot reload entry point" + return + } + if (!binder.asBinder().isBinderAlive) { + status = IXposedService.HOT_RELOAD_PROCESS_DIED + message = "Process ${target.processName} is gone" + return + } + val newModule = ConfigCache.state.modules[loadedModule.packageName] + if (newModule == null) { + status = IXposedService.HOT_RELOAD_UNSUPPORTED + message = "No installed generation of ${loadedModule.packageName} to load" + return + } + + // A cached target is usually frozen, and a transaction to a frozen process never reaches the + // module. Thawing first is what keeps that case from being reported as a refusal. + refreeze = ProcessFreezer.thaw(target.uid, target.pid) + if (refreeze == null && ProcessFreezer.isFrozen(target.uid, target.pid)) { + status = IXposedService.HOT_RELOAD_FAILED + message = "Target process is frozen and could not be thawed" + return + } + + val outcome = binder.hotReload(loadedModule.packageName, data, newModule) + status = outcome.status + // A null message is reserved for a module refusal. Anything else that arrives without one + // gets a framework-provided string, so the module app can tell the two apart. + message = + outcome.message + ?: if (status == IXposedService.HOT_RELOAD_FAILED && !outcome.refused) { + "Hot reload failed without a diagnostic message" + } else { + null + } + } catch (e: DeadObjectException) { + status = IXposedService.HOT_RELOAD_PROCESS_DIED + message = "Process ${target.processName} died during hot reload" + } catch (t: Throwable) { + status = IXposedService.HOT_RELOAD_FAILED + message = "${t.javaClass.name}: ${t.message ?: "no message"}" + Log.e(TAG, "Hot reload of ${loadedModule.packageName} failed", t) + } finally { + refreeze?.invoke() + ApplicationService.endHotReload(target, stateFor(status)) + report(callback, status, message) + } } + private fun stateFor(status: Int): Int = + when (status) { + IXposedService.HOT_RELOAD_SUCCEEDED -> HookedProcess.TARGET_STATE_UP_TO_DATE + IXposedService.HOT_RELOAD_FAILED -> HookedProcess.TARGET_STATE_FAILED + // Unsupported and process-died say nothing about the generation the target is running. + else -> HookedProcess.TARGET_STATE_STALE + } + private fun report(callback: IHotReloadCallback?, status: Int, message: String?) { // The callback is oneway, so a dead module app must not surface as a failure here. runCatching { callback?.onHotReloadResult(status, message) } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt new file mode 100644 index 000000000..8a08c6b6e --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt @@ -0,0 +1,57 @@ +package org.matrix.vector.daemon.system + +import android.util.Log +import java.io.File + +private const val TAG = "VectorFreezer" + +/** + * Thaws a frozen process for the duration of a daemon-initiated transaction. + * + * Android freezes cached processes, and a module's hooked targets are usually cached ones. A binder + * transaction does not reach a frozen process, so without this a hot reload of a backgrounded target + * fails without ever running module code - indistinguishable from the module returning false from + * onHotReloading, which is the one case the API reserves a null message for. + */ +object ProcessFreezer { + + /** + * The freezer is a cgroup v2 file. Newer releases give each process its own group; older ones and + * some vendor trees freeze at the uid level, so both layouts are probed. + */ + private fun freezeFile(uid: Int, pid: Int): File? = + sequenceOf( + "/sys/fs/cgroup/apps/uid_$uid/pid_$pid/cgroup.freeze", + "/sys/fs/cgroup/system/uid_$uid/pid_$pid/cgroup.freeze", + "/sys/fs/cgroup/apps/uid_$uid/cgroup.freeze", + "/sys/fs/cgroup/system/uid_$uid/cgroup.freeze", + ) + .map(::File) + .firstOrNull { it.exists() } + + fun isFrozen(uid: Int, pid: Int): Boolean = + runCatching { freezeFile(uid, pid)?.readText()?.trim() == "1" }.getOrDefault(false) + + /** + * Thaws the process if it is frozen and returns an action that restores the previous state, or + * null if nothing was changed. The caller must run the returned action once the transaction is + * done, otherwise the process is left permanently runnable. + */ + fun thaw(uid: Int, pid: Int): (() -> Unit)? { + val file = freezeFile(uid, pid) ?: return null + val wasFrozen = runCatching { file.readText().trim() == "1" }.getOrDefault(false) + if (!wasFrozen) return null + + val thawed = runCatching { file.writeText("0") }.isSuccess + if (!thawed) { + Log.w(TAG, "Cannot thaw uid=$uid pid=$pid through ${file.path}") + return null + } + + Log.d(TAG, "Thawed uid=$uid pid=$pid for a daemon transaction") + return { + runCatching { file.writeText("1") } + .onFailure { Log.w(TAG, "Cannot re-freeze uid=$uid pid=$pid", it) } + } + } +} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl new file mode 100644 index 000000000..7ad9d01de --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl @@ -0,0 +1,21 @@ +package org.lsposed.lspd.models; + +/** + * Result of a hot reload performed inside a hooked process. + */ +parcelable HotReloadOutcome { + /** One of IXposedService.HOT_RELOAD_*. */ + int status; + + /** + * Diagnostic message. Null is reserved for a module refusal, so every other failure has to + * carry a message even when the module's own exception had none. + */ + String message; + + /** + * True only when onHotReloading returned false. This is what lets the daemon keep a null + * message for a genuine refusal while supplying one for every other failure. + */ + boolean refused; +} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl new file mode 100644 index 000000000..35d34e767 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl @@ -0,0 +1,20 @@ +package org.lsposed.lspd.service; + +import org.lsposed.lspd.models.HotReloadOutcome; +import org.lsposed.lspd.models.Module; + +/** + * Daemon-to-process entry point for hot reloading a module generation in place. + * + *

Registered once per process while the framework bootstraps, before any module is loaded, so + * that a target exists regardless of when the daemon's module cache becomes available.

+ */ +interface IHotReloadTarget { + /** + * Replaces the loaded generation of modulePackageName with newModule. + * + *

Runs the old code's onHotReloading and the new code's onHotReloaded, and blocks for their + * duration; the daemon calls this off the binder thread that served the module app.

+ */ + HotReloadOutcome hotReload(String modulePackageName, in Bundle extras, in Module newModule) = 1; +} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl index b85b6ed21..8459c0c9a 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl @@ -1,8 +1,16 @@ package org.lsposed.lspd.service; import org.lsposed.lspd.models.Module; +import org.lsposed.lspd.service.IHotReloadTarget; interface ILSPApplicationService { + /** + * Registers this process's hot reload entry point. Called once while the framework bootstraps, + * independently of module loading, so that system_server - whose modules are loaded before the + * daemon's module cache exists - is a reloadable target like any other process. + */ + void registerHotReloadTarget(IHotReloadTarget target); + boolean isLogMuted(); List getLegacyModulesList(); 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 b3b2de1c6..a8f2cebc7 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -29,6 +29,20 @@ class VectorContext( private val remotePrefs = ConcurrentHashMap() + // Set when a hot reload retires this generation. Keyed on the module generation rather than on + // the hooker's classloader, which says nothing about which generation registered a hook. + @Volatile private var frozen = false + + /** Stops this generation from registering further hooks. Existing handles keep working. */ + fun freeze() { + frozen = true + } + + /** Undoes [freeze] when a hot reload was refused or rolled back. */ + fun unfreeze() { + frozen = false + } + override fun getFrameworkName(): String = BuildConfig.FRAMEWORK_NAME override fun getFrameworkVersion(): String = BuildConfig.VERSION_NAME @@ -40,14 +54,14 @@ class VectorContext( } override fun hook(origin: Executable): XposedInterface.HookBuilder { - return VectorHookBuilder(origin, packageName) + return VectorHookBuilder(origin, packageName) { frozen } } override fun hookClassInitializer(origin: Class<*>): XposedInterface.HookBuilder { val clinit = HookBridge.getStaticInitializer(origin) ?: throw IllegalArgumentException("Class ${origin.name} has no static initializer") - return VectorHookBuilder(clinit, packageName) + return VectorHookBuilder(clinit, packageName) { frozen } } override fun deoptimize(executable: Executable): Boolean { diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt index 19ce25d46..762edc62a 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt @@ -30,6 +30,9 @@ object VectorLifecycleManager { } } + /** Whether [module] still receives callbacks, i.e. has not detached. */ + fun isActive(module: XposedModule): Boolean = activeModules.contains(module) + fun dispatchPackageLoaded( packageName: String, appInfo: ApplicationInfo, diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt new file mode 100644 index 000000000..34b910f5f --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt @@ -0,0 +1,22 @@ +package org.matrix.vector.impl.core + +import android.os.Bundle +import org.lsposed.lspd.models.HotReloadOutcome +import org.lsposed.lspd.models.Module +import org.lsposed.lspd.service.IHotReloadTarget + +/** + * The process's hot reload entry point. Registered once while the framework bootstraps, before any + * module is loaded, so the daemon has a target regardless of when modules arrive. + * + * The call blocks for the whole reload cycle; the daemon calls it off the binder thread that served + * the module app. + */ +object VectorHotReloadTarget : IHotReloadTarget.Stub() { + + override fun hotReload( + modulePackageName: String?, + extras: Bundle?, + newModule: Module?, + ): HotReloadOutcome = VectorModuleManager.hotReload(modulePackageName, extras, newModule) +} 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 ad448da20..6e128b589 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 @@ -1,14 +1,24 @@ package org.matrix.vector.impl.core import android.os.Build +import android.os.Bundle import android.os.Process +import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface.HotReloadedParam +import io.github.libxposed.api.XposedModuleInterface.HotReloadingParam import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam +import io.github.libxposed.service.IXposedService import java.io.File +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import org.lsposed.lspd.models.HotReloadOutcome import org.lsposed.lspd.models.Module import org.lsposed.lspd.util.Utils.Log import org.matrix.vector.impl.VectorContext import org.matrix.vector.impl.VectorLifecycleManager +import org.matrix.vector.impl.hooks.VectorHookBuilder import org.matrix.vector.impl.utils.VectorModuleClassLoader import org.matrix.vector.nativebridge.NativeAPI @@ -20,10 +30,75 @@ object VectorModuleManager { private const val TAG = "VectorModuleManager" + /** + * One loaded generation of a module: the code a hot reload replaces. + * + * Entry instances are referenced weakly on purpose. `VectorLifecycleManager.activeModules` owns + * the framework's only strong reference to them, and detach is specified to remove it, so a + * generation resolves its entries through [liveEntries] instead of keeping a list of its own. A + * hot reload turns that into a local strong list for the duration of the cycle, which is what + * keeps the old generation reachable until `onHotReloaded` finishes. + */ + private class Generation( + val classLoader: ClassLoader, + val context: VectorContext, + entries: List, + val isSystemServer: Boolean, + val processName: String, + ) { + private val entryRefs = entries.map { WeakReference(it) } + + fun liveEntries(): List = + entryRefs.mapNotNull { it.get() }.filter { VectorLifecycleManager.isActive(it) } + } + + private val generations = ConcurrentHashMap() + + // Reloads are serialized per module within this process; the daemon serializes per target. + private val reloadLocks = ConcurrentHashMap() + /** * Loads a module APK, instantiates its entry classes, and binds them to the Vector framework. */ fun loadModule(module: Module, isSystemServer: Boolean, processName: String): Boolean { + val (generation, entries) = + buildGeneration(module, isSystemServer, processName) ?: return false + + entries.forEach { VectorLifecycleManager.activeModules.add(it) } + generations[module.packageName] = generation + + val param = + object : ModuleLoadedParam { + override fun isSystemServer(): Boolean = isSystemServer + + override fun getProcessName(): String = processName + } + entries.forEach { entry -> + runCatching { entry.onModuleLoaded(param) } + .onFailure { e -> + Log.e(TAG, "Error in onModuleLoaded for ${entry.javaClass.name}", e) + } + } + + // Register any native JNI entrypoints declared by the module + module.file.moduleLibraryNames.forEach { libraryName -> + NativeAPI.recordNativeEntrypoint(libraryName) + } + + Log.d(TAG, "Loaded module ${module.packageName} successfully.") + return true + } + + /** + * Creates the classloader, the framework context and the entry instances of one generation. + * Nothing is published: the caller decides when the entries become active, which is what lets a + * hot reload fail cleanly before the old generation is touched. + */ + private fun buildGeneration( + module: Module, + isSystemServer: Boolean, + processName: String, + ): Pair>? { try { Log.d(TAG, "Loading module ${module.packageName}") @@ -54,7 +129,7 @@ object VectorModuleManager { initLoader ) { Log.e(TAG, "The Xposed API classes are compiled into ${module.packageName}") - return false + return null } // Create the Context that will be injected into the module @@ -66,6 +141,7 @@ object VectorModuleManager { ) // Instantiate the module entry classes + val entries = mutableListOf() for (className in module.file.moduleClassNames) { runCatching { val moduleClass = moduleClassLoader.loadClass(className) @@ -86,31 +162,211 @@ object VectorModuleManager { VectorLifecycleManager.detach(moduleInstance) } - // Register the active module to receive future lifecycle events - VectorLifecycleManager.activeModules.add(moduleInstance) - - // Trigger the initial onModuleLoaded callback - moduleInstance.onModuleLoaded( - object : ModuleLoadedParam { - override fun isSystemServer(): Boolean = isSystemServer - - override fun getProcessName(): String = processName - } - ) + entries.add(moduleInstance) } .onFailure { e -> Log.e(TAG, "Failed to instantiate class $className", e) } } - // Register any native JNI entrypoints declared by the module - module.file.moduleLibraryNames.forEach { libraryName -> - NativeAPI.recordNativeEntrypoint(libraryName) - } - - Log.d(TAG, "Loaded module ${module.packageName} successfully.") - return true + val generation = + Generation(moduleClassLoader, vectorContext, entries, isSystemServer, processName) + return generation to entries } catch (e: Throwable) { Log.e(TAG, "Fatal error loading module ${module.packageName}", e) - return false + return null + } + } + + /** + * Replaces the loaded generation of [modulePackageName] with [newModule], running the old code's + * `onHotReloading` and the new code's `onHotReloaded`. Blocks for the whole cycle. + */ + fun hotReload( + modulePackageName: String?, + extras: Bundle?, + newModule: Module?, + ): HotReloadOutcome { + val packageName = + modulePackageName ?: return unsupported("Hot reload was requested without a module") + val lock = reloadLocks.computeIfAbsent(packageName) { ReentrantLock() } + if (!lock.tryLock()) { + return outcome( + IXposedService.HOT_RELOAD_IN_PROGRESS, + "A reload of $packageName is already running in this process", + ) + } + return try { + runHotReload(packageName, extras, newModule) + } catch (t: Throwable) { + Log.e(TAG, "Hot reload of $packageName failed", t) + failed(describe(t)) + } finally { + lock.unlock() } } + + private fun runHotReload( + packageName: String, + extras: Bundle?, + newModule: Module?, + ): HotReloadOutcome { + if (newModule == null) { + return unsupported("No new generation of $packageName was supplied") + } + val old = + generations[packageName] + ?: return unsupported( + "$packageName is not loaded in ${VectorServiceClient.processName}" + ) + if (newModule.file.moduleClassNames.size != 1) { + return unsupported("$packageName does not declare exactly one Java entry class") + } + + // Strong references for the whole cycle: the old generation must stay reachable until + // onHotReloaded has finished. + val oldEntries = old.liveEntries() + if (oldEntries.isEmpty()) { + // Every entry has detached, so no module code can be asked. This must not be reported as + // a refusal: a null message means onHotReloading returned false, and nothing ran here. + Log.w(TAG, "No attached entry of $packageName can accept a hot reload") + return unsupported("Every entry of $packageName has detached in this process") + } + + val built = + buildGeneration(newModule, old.isSystemServer, old.processName) + ?: return unsupported("Cannot build a new generation of $packageName") + val (newGeneration, newEntries) = built + + // Freeze before the callback runs, so hook registrations attempted by old code from inside + // onHotReloading fail while the unhook and replace paths it needs keep working. + old.context.freeze() + + var savedState: Any? = null + val reloadingParam = + object : HotReloadingParam { + override fun getExtras(): Bundle? = extras + + override fun setSavedInstanceState(outState: Any?) { + rejectOldGenerationState(outState, old.classLoader) + savedState = outState + } + } + + val accepted = + try { + // One refusal cancels the reload for the whole module. + oldEntries.all { it.onHotReloading(reloadingParam) } + } catch (t: Throwable) { + old.context.unfreeze() + Log.e(TAG, "onHotReloading of $packageName threw", t) + return failed(describe(t)) + } + if (!accepted) { + old.context.unfreeze() + Log.d(TAG, "$packageName refused the hot reload") + return refusal() + } + + // Captured after the freeze and after old code had its chance to unhook. + val oldHandles = VectorHookBuilder.snapshotHandles(packageName) + // The id registry strongly references hook records and through them the old classloader. + // Dropping the old generation's entries here also makes everything tracked from now on + // belong to the new generation, which is what the rollback below unhooks. + VectorHookBuilder.releaseModule(packageName) + + oldEntries.forEach { VectorLifecycleManager.activeModules.remove(it) } + // The new entries have to be active before the callback so that an entry detaching from + // inside onHotReloaded is honoured like any other detach. + newEntries.forEach { VectorLifecycleManager.activeModules.add(it) } + + val reloadedParam = + object : HotReloadedParam { + override fun isSystemServer(): Boolean = old.isSystemServer + + override fun getProcessName(): String = old.processName + + override fun getExtras(): Bundle? = extras + + override fun getSavedInstanceState(): Any? = savedState + + override fun getOldHookHandles(): List = oldHandles + } + + try { + // The old hooks are not unhooked here: the default onHotReloaded already does that, and + // doing both would double-unhook. + newEntries.filter { VectorLifecycleManager.isActive(it) }.forEach { + it.onHotReloaded(reloadedParam) + } + } catch (t: Throwable) { + // Nothing has been committed yet, so the old generation is still the live one. + VectorHookBuilder.unhookAll(packageName) + newEntries.forEach { VectorLifecycleManager.activeModules.remove(it) } + oldEntries.forEach { VectorLifecycleManager.activeModules.add(it) } + old.context.unfreeze() + Log.e(TAG, "onHotReloaded of $packageName threw; kept the previous generation", t) + return failed(describe(t)) + } + + // Commit only now that the new code has taken over. Replacing the map entry drops the last + // framework-owned reference to the old generation; oldEntries dies with this frame. + generations[packageName] = newGeneration + Log.d(TAG, "Hot reloaded $packageName") + return outcome(IXposedService.HOT_RELOAD_SUCCEEDED, null) + } + + /** + * Rejects saved state that the old generation created, which would otherwise keep the retired + * classloader reachable through the new one. A shallow scan, as the API describes it: a + * diagnostic aid rather than an object graph verifier. + */ + private fun rejectOldGenerationState(state: Any?, oldClassLoader: ClassLoader) { + if (state == null) return + reject(state, oldClassLoader) + when (state) { + is Array<*> -> state.forEach { it?.let { e -> reject(e, oldClassLoader) } } + is Collection<*> -> state.forEach { it?.let { e -> reject(e, oldClassLoader) } } + is Map<*, *> -> + state.forEach { (k, v) -> + k?.let { reject(it, oldClassLoader) } + v?.let { reject(it, oldClassLoader) } + } + } + } + + private fun reject(value: Any, oldClassLoader: ClassLoader) { + if (definedBy(value.javaClass, oldClassLoader)) { + throw IllegalArgumentException( + "Saved instance state contains ${value.javaClass.name}, which was created under " + + "the old module classloader" + ) + } + } + + /** Whether [clazz] was defined by [classLoader] or by any loader descending from it. */ + private fun definedBy(clazz: Class<*>, classLoader: ClassLoader): Boolean { + var loader: ClassLoader? = + (if (clazz.isArray) clazz.componentType else clazz)?.classLoader + while (loader != null) { + if (loader === classLoader) return true + loader = loader.parent + } + return false + } + + private fun outcome(status: Int, message: String?, refused: Boolean = false) = + HotReloadOutcome().apply { + this.status = status + this.message = message + this.refused = refused + } + + private fun unsupported(message: String) = outcome(IXposedService.HOT_RELOAD_UNSUPPORTED, message) + + private fun failed(message: String) = outcome(IXposedService.HOT_RELOAD_FAILED, message) + + /** A null message is reserved for a module refusal, so only this path may produce one. */ + private fun refusal() = outcome(IXposedService.HOT_RELOAD_FAILED, null, refused = true) + + /** Throwables carry no message often enough that the diagnostic has to be synthesized. */ + private fun describe(t: Throwable) = "${t.javaClass.name}: ${t.message ?: "no message"}" } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt index fc70e9097..163d4f31c 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt @@ -3,6 +3,7 @@ package org.matrix.vector.impl.core import android.os.IBinder import android.os.ParcelFileDescriptor import org.lsposed.lspd.models.Module +import org.lsposed.lspd.service.IHotReloadTarget import org.lsposed.lspd.service.ILSPApplicationService import org.lsposed.lspd.util.Utils.Log @@ -31,6 +32,24 @@ object VectorServiceClient : ILSPApplicationService, IBinder.DeathRecipient { Log.e(TAG, "Failed to link to death for service in process: $niceName", it) service = null } + + // Registered here rather than after module loading: system_server loads its modules + // before the daemon's module cache exists, and it has to be a reloadable target too. + service?.let { + try { + it.registerHotReloadTarget(VectorHotReloadTarget) + } catch (t: Throwable) { + Log.e(TAG, "Failed to register the hot reload target in process: $niceName", t) + } + } + } + } + + override fun registerHotReloadTarget(target: IHotReloadTarget?) { + try { + service?.registerHotReloadTarget(target) + } catch (t: Throwable) { + Log.e(TAG, "Failed to register a hot reload target", t) } } 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 ce01675d2..ac898799b 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 @@ -20,9 +20,16 @@ import org.matrix.vector.nativebridge.HookBridge * * [moduleId] identifies the owning module so hook ids can be isolated between modules; it is null for * framework-internal hooks, which never use ids. + * + * [frozen] reports whether the generation that created this builder has been retired by a hot + * reload. It gates registration only: the handles a frozen generation already holds keep working, as + * `onHotReloading` requires modules to unregister from inside the callback. */ -class VectorHookBuilder(private val origin: Executable, private val moduleId: Any? = null) : - HookBuilder { +class VectorHookBuilder( + private val origin: Executable, + private val moduleId: Any? = null, + private val frozen: (() -> Boolean)? = null, +) : HookBuilder { private var priority = XposedInterface.PRIORITY_DEFAULT private var exceptionMode = ExceptionMode.DEFAULT @@ -37,6 +44,11 @@ class VectorHookBuilder(private val origin: Executable, private val moduleId: An override fun setId(id: String?): HookBuilder = apply { this.id = id } override fun intercept(hooker: Hooker): HookHandle { + if (frozen?.invoke() == true) { + throw IllegalStateException( + "This module generation has been retired by a hot reload and cannot register hooks" + ) + } if (Modifier.isAbstract(origin.modifiers)) { throw IllegalArgumentException("Cannot hook abstract methods: $origin") } else if (origin.declaringClass.classLoader == VectorHookBuilder::class.java.classLoader) { @@ -83,6 +95,7 @@ class VectorHookBuilder(private val origin: Executable, private val moduleId: An idRegistry.remove(key, candidate) throw HookFailedError("Cannot hook $origin") } + track(candidate) return handleFor(candidate, candidate.epoch.get()) } @@ -95,45 +108,105 @@ class VectorHookBuilder(private val origin: Executable, private val moduleId: An throw HookFailedError("Cannot hook $origin") } + track(record) return handleFor(record, record.epoch.get()) } - /** - * Creates a handle bound to [record] and the epoch at which it became valid. The handle is stale - * once the record is replaced (epoch moves on) or unhooked. - */ + /** Records an installed hook against its owning module. Framework hooks have no module. */ + private fun track(record: VectorHookRecord) { + val moduleId = this.moduleId ?: return + moduleHooks + .computeIfAbsent(moduleId) { ConcurrentHashMap.newKeySet() } + .add(InstalledHook(origin, record)) + } + private fun handleFor(record: VectorHookRecord, epoch: Int): HookHandle = - object : HookHandle { - override fun getExecutable(): Executable = origin + handleFor(origin, moduleId, record, epoch) + + companion object { + // Registry of id-bearing hooks keyed by (module, executable, id). Lets a repeated intercept() + // with an already-registered id reuse the installed record. Ids are isolated per module. + private val idRegistry = ConcurrentHashMap() + + // Hooks a module currently has installed, so a hot reload can hand the old generation's + // handles to the new one and can unhook a new generation that failed to take over. + private val moduleHooks = ConcurrentHashMap>() + + /** + * Creates a handle bound to [record] and the epoch at which it became valid. The handle is + * stale once the record is replaced (epoch moves on) or unhooked. + */ + private fun handleFor( + origin: Executable, + moduleId: Any?, + record: VectorHookRecord, + epoch: Int, + ): HookHandle = + object : HookHandle { + override fun getExecutable(): Executable = origin - override fun getId(): String? = record.id + override fun getId(): String? = record.id - override fun unhook() { - if (record.installed.compareAndSet(true, false)) { - HookBridge.unhookMethod(true, origin, record) - record.id?.let { idRegistry.remove(IdKey(moduleId, origin, it), record) } + override fun unhook() { + if (record.installed.compareAndSet(true, false)) { + HookBridge.unhookMethod(true, origin, record) + record.id?.let { idRegistry.remove(IdKey(moduleId, origin, it), record) } + moduleId?.let { + moduleHooks[it]?.remove(InstalledHook(origin, record)) + } + } } - } - override fun replaceHook(hooker: Hooker): HookHandle { - // Valid only while still installed and no replacement happened since this handle. - // The epoch CAS also makes concurrent replacements from the same handle mutually - // exclusive. - if (!record.installed.get() || !record.epoch.compareAndSet(epoch, epoch + 1)) { - throw IllegalStateException("Hook handle is no longer valid") + override fun replaceHook(hooker: Hooker): HookHandle { + // Valid only while still installed and no replacement happened since this + // handle. The epoch CAS also makes concurrent replacements from the same handle + // mutually exclusive. + if (!record.installed.get() || !record.epoch.compareAndSet(epoch, epoch + 1)) { + throw IllegalStateException("Hook handle is no longer valid") + } + record.hooker = hooker + return handleFor(origin, moduleId, record, epoch + 1) } - record.hooker = hooker - return handleFor(record, epoch + 1) } + + /** + * Fresh handles for every hook [moduleId] currently has installed. Handles are minted at the + * current epoch so the receiver can still replace them, which is what hot reload hands to + * `HotReloadedParam.getOldHookHandles()`. + */ + fun snapshotHandles(moduleId: Any): List = + moduleHooks[moduleId] + ?.filter { it.record.installed.get() } + ?.map { handleFor(it.origin, moduleId, it.record, it.record.epoch.get()) } + ?: emptyList() + + /** + * Drops the framework-owned registrations of a module generation. The id registry + * strongly references hook records and through them the module classloader, so a hot reload + * that skipped this would leak one classloader per generation. + * + * Hooks themselves stay installed; hot reload hands their handles to the new generation. + */ + fun releaseModule(moduleId: Any) { + idRegistry.keys.removeIf { it.moduleId == moduleId } + moduleHooks.remove(moduleId) } - companion object { - // Registry of id-bearing hooks keyed by (module, executable, id). Lets a repeated intercept() - // with an already-registered id reuse the installed record. Ids are isolated per module. - private val idRegistry = ConcurrentHashMap() + /** Unhooks everything currently tracked for [moduleId] and forgets it. */ + fun unhookAll(moduleId: Any) { + moduleHooks.remove(moduleId)?.forEach { hook -> + if (hook.record.installed.compareAndSet(true, false)) { + HookBridge.unhookMethod(true, hook.origin, hook.record) + } + } + idRegistry.keys.removeIf { it.moduleId == moduleId } + } } } +/** An installed hook and the executable it was installed on. */ +private data class InstalledHook(val origin: Executable, val record: VectorHookRecord) + /** Registry key scoping a hook id to its owning module and executable. */ private data class IdKey(val moduleId: Any?, val executable: Executable, val id: String) From 5ae6eb422c4b1b0bb7f02b5c6923c3da68a10ece Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 18:37:46 +0800 Subject: [PATCH 6/8] Keep hook tracking across a hot reload Measured on device: getOldHookHandles() returned two handles on the first reload of a process and none on every reload after it, while the hooks themselves stayed installed and serving. The reload path dropped the module's whole tracking set once the old handles had been captured. That is wrong for the replacement design: replaceHook swaps the hooker inside a record that is already installed, so forgetting the record leaves a live hook the framework can no longer hand back. The damage is worse than a missing list, because the default onHotReloaded unhooks exactly the handles it is given - an empty list means the retired hookers, and the classloader behind them, are never released. Tracking and the id registry now survive a reload, keyed by package name as they already were, and the rollback undoes only what the incoming generation added on top of the set it inherited. Verified on device: four consecutive reloads now report two old handles each. --- .../vector/impl/core/VectorModuleManager.kt | 11 ++--- .../vector/impl/hooks/VectorNativeHooker.kt | 41 +++++++++++-------- 2 files changed, 31 insertions(+), 21 deletions(-) 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 6e128b589..d616bb42f 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 @@ -268,10 +268,11 @@ object VectorModuleManager { // Captured after the freeze and after old code had its chance to unhook. val oldHandles = VectorHookBuilder.snapshotHandles(packageName) - // The id registry strongly references hook records and through them the old classloader. - // Dropping the old generation's entries here also makes everything tracked from now on - // belong to the new generation, which is what the rollback below unhooks. - VectorHookBuilder.releaseModule(packageName) + // The hooks carried into this cycle. Tracking has to survive the reload: replaceHook swaps + // the hooker inside a record that stays installed, so dropping it would hand the next + // generation an empty handle list and strand the retired hookers. The rollback below undoes + // only what the incoming generation adds on top of this set. + val inherited = VectorHookBuilder.trackedRecords(packageName) oldEntries.forEach { VectorLifecycleManager.activeModules.remove(it) } // The new entries have to be active before the callback so that an entry detaching from @@ -299,7 +300,7 @@ object VectorModuleManager { } } catch (t: Throwable) { // Nothing has been committed yet, so the old generation is still the live one. - VectorHookBuilder.unhookAll(packageName) + VectorHookBuilder.unhookSince(packageName, inherited) newEntries.forEach { VectorLifecycleManager.activeModules.remove(it) } oldEntries.forEach { VectorLifecycleManager.activeModules.add(it) } old.context.unfreeze() 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 ac898799b..3551ac495 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 @@ -181,25 +181,34 @@ class VectorHookBuilder( ?: emptyList() /** - * Drops the framework-owned registrations of a module generation. The id registry - * strongly references hook records and through them the module classloader, so a hot reload - * that skipped this would leak one classloader per generation. - * - * Hooks themselves stay installed; hot reload hands their handles to the new generation. + * The records [moduleId] has tracked right now, so a hot reload can tell the hooks it + * inherited from the ones the incoming generation adds. */ - fun releaseModule(moduleId: Any) { - idRegistry.keys.removeIf { it.moduleId == moduleId } - moduleHooks.remove(moduleId) - } + fun trackedRecords(moduleId: Any): Set = + moduleHooks[moduleId]?.mapTo(mutableSetOf()) { it.record } ?: emptySet() - /** Unhooks everything currently tracked for [moduleId] and forgets it. */ - fun unhookAll(moduleId: Any) { - moduleHooks.remove(moduleId)?.forEach { hook -> - if (hook.record.installed.compareAndSet(true, false)) { - HookBridge.unhookMethod(true, hook.origin, hook.record) + /** + * Unhooks what [moduleId] gained since [keep] was taken, leaving inherited hooks installed. + * + * Tracking deliberately survives a reload. `replaceHook` swaps the hooker inside the record + * that is already installed, so forgetting the record would leave a live hook the framework + * can no longer hand back through `getOldHookHandles()` - and since the default + * `onHotReloaded` unhooks exactly those handles, an empty list means the retired hookers, + * and the classloader behind them, are never released. + */ + fun unhookSince(moduleId: Any, keep: Set) { + val tracked = moduleHooks[moduleId] ?: return + tracked + .filter { it.record !in keep } + .forEach { hook -> + if (hook.record.installed.compareAndSet(true, false)) { + HookBridge.unhookMethod(true, hook.origin, hook.record) + } + tracked.remove(hook) + hook.record.id?.let { + idRegistry.remove(IdKey(moduleId, hook.origin, it), hook.record) + } } - } - idRegistry.keys.removeIf { it.moduleId == moduleId } } } } From 1380233bab8cad1957243814fa7142a1ed5c4f7b Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 18:38:04 +0800 Subject: [PATCH 7/8] Append the hot reload registration instead of inserting it ILSPApplicationService leaves transaction ids implicit, so a method added at the top renumbers every method below it. Everything ships in one zip, so it worked, but it is the mistake the interface's own layout invites and the reason upstream pins ids explicitly elsewhere. --- .../lspd/service/ILSPApplicationService.aidl | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl index 8459c0c9a..c8fa3c8cb 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl @@ -4,13 +4,6 @@ import org.lsposed.lspd.models.Module; import org.lsposed.lspd.service.IHotReloadTarget; interface ILSPApplicationService { - /** - * Registers this process's hot reload entry point. Called once while the framework bootstraps, - * independently of module loading, so that system_server - whose modules are loaded before the - * daemon's module cache exists - is a reloadable target like any other process. - */ - void registerHotReloadTarget(IHotReloadTarget target); - boolean isLogMuted(); List getLegacyModulesList(); @@ -20,4 +13,14 @@ interface ILSPApplicationService { String getPrefsPath(String packageName); ParcelFileDescriptor requestInjectedManagerBinder(out List binder); + + /** + * Registers this process's hot reload entry point. Called once while the framework bootstraps, + * independently of module loading, so that system_server - whose modules are loaded before the + * daemon's module cache exists - is a reloadable target like any other process. + * + *

Appended rather than inserted: this interface leaves transaction ids implicit, so adding a + * method anywhere above would renumber every method after it.

+ */ + void registerHotReloadTarget(IHotReloadTarget target); } From 485c387b8686ffec55f977a8c73305a0774d228b Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 26 Jul 2026 18:38:04 +0800 Subject: [PATCH 8/8] Show the Xposed API version a module targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module list only used minVersion and targetVersion to raise a warning when they were out of range, so the API a module was built against - which decides how the framework loads it - was never visible next to the module itself. It now sits on the version line, as a range when min and target differ, and is omitted entirely when a module declares no metadata rather than rendering "API 0". The CLI reported the framework version but had no way to show the API version the manager already reads over binder. zh-rTW was missing menu_select, menu_select_all, menu_select_none and menu_auto_include. That file is localised for Taiwan rather than converted from zh-rCN - it uses 程式, 儲存 and 停用 - so the additions follow the same conventions instead of copying zh-rHK. --- .../manager/ui/fragment/ModulesFragment.java | 15 ++++++++++++++- app/src/main/res/values-zh-rCN/strings.xml | 3 +-- app/src/main/res/values-zh-rTW/strings.xml | 6 +++++- app/src/main/res/values/strings.xml | 2 ++ .../org/matrix/vector/daemon/ipc/CliHandler.kt | 2 ++ 5 files changed, 24 insertions(+), 4 deletions(-) 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..56dd2b7bc 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 @@ -642,7 +642,7 @@ public void onLoadCleared(@Nullable Drawable placeholder) { } }); holder.appVersion.setVisibility(View.VISIBLE); - holder.appVersion.setText(item.versionName); + holder.appVersion.setText(formatVersion(item)); holder.appVersion.setSelected(true); } else { holder.itemView.setTag(item); @@ -652,6 +652,19 @@ public void onLoadCleared(@Nullable Drawable placeholder) { } } + /** + * The module version line, with the Xposed API the module was built against. Which API a + * module targets decides how the framework loads it, so it belongs next to the version + * rather than only in the warning that fires when it is out of range. + */ + private String formatVersion(ModuleUtil.InstalledModule item) { + if (item.targetVersion <= 0) return item.versionName; + var api = item.minVersion > 0 && item.minVersion != item.targetVersion + ? getString(R.string.module_api_version_range, item.minVersion, item.targetVersion) + : getString(R.string.module_api_version, item.targetVersion); + return item.versionName + " · " + api; + } + @Override public void onViewRecycled(@NonNull ViewHolder holder) { holder.itemView.setTag(null); diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2f3fc89c9..5616471fb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -37,8 +37,7 @@ %d 个模块可更新 加入我们的 %2$s 频道
加入我们的 QQ 频道]]>
- LSPosed -JingMatrix + LSPosed、JingMatrix 安装 点击安装 LSPosed 未安装 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 82de0478d..c77fce0ce 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -37,7 +37,7 @@ %d 個模組可更新 加入我們的 %2$s 頻道]]> - 孟武. 尼德霍格. 龍、david082321、beigua87 + 孟武. 尼德霍格. 龍、david082321、beigua87、HSSkyBoy 安裝 點選安裝 LSPosed 未安裝 @@ -138,9 +138,13 @@ 模組 作用域列表儲存失敗 版本:%1$s + 選擇 推薦程式 未選擇任何程式,選擇推薦的程式? 選擇推薦的程式? + 全部 + + 自動加入 Xposed 模組尚未啟用 推薦啟用 可用更新:%1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d3c4dbb2..064d2e8d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,8 @@ Tips for module developer Please disable deploy optimizations on Android Studio, or use `gradlew installDebug` command to install. Otherwise the module apk will not be updated. API version + API %1$d + API %1$d–%2$d Framework version Manager package name System version 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..c2b8dfe55 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 @@ -4,6 +4,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import org.lsposed.lspd.models.Application +import io.github.libxposed.service.IXposedService import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.CliRequest import org.matrix.vector.daemon.CliResponse @@ -40,6 +41,7 @@ object CliHandler { return mapOf( "Framework Version" to BuildConfig.VERSION_NAME, "Version Code" to BuildConfig.VERSION_CODE, + "API Version" to IXposedService.LIB_API, "Enabled Modules" to ConfigCache.state.modules.size, "Status Notification" to PreferenceStore.isStatusNotificationEnabled()) }