From df2c1fa589fed3480de02b62bb189a69a623cb89 Mon Sep 17 00:00:00 2001 From: NkBe Date: Fri, 29 May 2026 22:38:54 +0800 Subject: [PATCH 1/2] Implement module hot reload support --- .../matrix/vector/daemon/data/ConfigCache.kt | 4 + .../vector/daemon/ipc/ApplicationService.kt | 104 ++++++++++ .../matrix/vector/daemon/ipc/ModuleService.kt | 36 ++++ services/daemon-service/build.gradle.kts | 2 +- .../aidl/org/lsposed/lspd/models/Module.aidl | 1 + .../lspd/service/IHotReloadTarget.aidl | 7 + .../lspd/service/ILSPApplicationService.aidl | 3 + services/libxposed | 2 +- .../libxposed/annotation/InternalApi.java | 18 ++ .../github/libxposed/annotation/SinceApi.java | 20 ++ xposed/build.gradle.kts | 2 +- xposed/libxposed | 2 +- .../org/matrix/vector/impl/VectorContext.kt | 4 +- .../vector/impl/VectorLifecycleManager.kt | 4 + .../vector/impl/core/VectorHotReloadTarget.kt | 11 ++ .../vector/impl/core/VectorModuleManager.kt | 187 ++++++++++++++---- .../vector/impl/core/VectorServiceClient.kt | 12 ++ .../matrix/vector/impl/hooks/BaseInvoker.kt | 4 +- .../matrix/vector/impl/hooks/VectorChain.kt | 21 +- .../vector/impl/hooks/VectorNativeHooker.kt | 154 +++++++++++++-- 20 files changed, 537 insertions(+), 61 deletions(-) create mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl create mode 100644 shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/InternalApi.java create mode 100644 shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/SinceApi.java 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/data/ConfigCache.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt index cfeb9d107..b5cc4a8f0 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 applicationInfo = appInfo service = oldModule?.service ?: InjectedModuleService(pkgName) file = preLoadedApk @@ -340,6 +341,8 @@ object ConfigCache { fun getModuleByUid(uid: Int): Module? = state.modules.values.firstOrNull { it.appId == uid % PER_USER_RANGE } + fun getModuleByPackage(packageName: String): Module? = state.modules[packageName] + fun getModulesForSystemServer(): List { val modules = mutableListOf() if (!android.os.SELinux.checkSELinuxAccess( @@ -376,6 +379,7 @@ object ConfigCache { packageName = pkgName this.apkPath = apkPath appId = runCatching { Os.stat(statPath).st_uid }.getOrDefault(-1) + versionCode = 0 service = InjectedModuleService(pkgName) } 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..7898d5502 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,9 +6,12 @@ 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.AtomicLong import org.lsposed.lspd.models.Module import org.lsposed.lspd.service.ILSPApplicationService +import org.lsposed.lspd.service.IHotReloadTarget import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem import org.matrix.vector.daemon.utils.InstallerVerifier @@ -29,6 +32,8 @@ object ApplicationService : ILSPApplicationService.Stub() { data class ProcessKey(val uid: Int, val pid: Int) private val processes = ConcurrentHashMap() + private val nextHotReloadTargetId = AtomicLong(1) + private val hotReloadTargets = ConcurrentHashMap() private class ProcessInfo(val key: ProcessKey, val processName: String, val heartBeat: IBinder) : IBinder.DeathRecipient { @@ -40,6 +45,45 @@ object ApplicationService : ILSPApplicationService.Stub() { override fun binderDied() { heartBeat.unlinkToDeath(this, 0) processes.remove(key) + hotReloadTargets.entries.removeIf { it.value.process === this } + } + } + + private class HotReloadTargetInfo( + val id: Long, + val modulePackageName: String, + val process: ProcessInfo, + @Volatile var loadedVersionCode: Long, + val target: IHotReloadTarget + ) : IBinder.DeathRecipient { + @Volatile var state: Int = HookedProcess.TARGET_STATE_UP_TO_DATE + + init { + target.asBinder().linkToDeath(this, 0) + hotReloadTargets[id] = this + } + + override fun binderDied() { + target.asBinder().unlinkToDeath(this, 0) + hotReloadTargets.remove(id) + } + + fun toHookedProcess(currentVersionCode: Long): HookedProcess { + val effectiveState = + if (state == HookedProcess.TARGET_STATE_UP_TO_DATE && + loadedVersionCode != currentVersionCode) { + HookedProcess.TARGET_STATE_STALE + } else { + state + } + return HookedProcess().apply { + targetId = id + uid = process.key.uid + pid = process.key.pid + processName = process.processName + state = effectiveState + loadedVersionCode = this@HotReloadTargetInfo.loadedVersionCode + } } } @@ -129,4 +173,64 @@ object ApplicationService : ILSPApplicationService.Stub() { .onFailure { Log.e(TAG, "Failed to open or verify manager APK", it) } .getOrNull() } + + override fun registerHotReloadTarget( + modulePackageName: String, + loadedVersionCode: Long, + target: IHotReloadTarget + ): Long { + val info = ensureRegistered() + val module = + ConfigCache.getModuleByPackage(modulePackageName) + ?: throw RemoteException("Unknown module: $modulePackageName") + if (!getAllModules().any { it.packageName == module.packageName }) { + throw RemoteException("Module $modulePackageName is not active in ${info.processName}") + } + + val existing = + hotReloadTargets.values.firstOrNull { + it.modulePackageName == modulePackageName && + it.process.key == info.key && + it.target.asBinder() == target.asBinder() + } + if (existing != null) { + existing.loadedVersionCode = loadedVersionCode + existing.state = HookedProcess.TARGET_STATE_UP_TO_DATE + return existing.id + } + + val id = nextHotReloadTargetId.getAndIncrement() + HotReloadTargetInfo(id, module.packageName, info, loadedVersionCode, target) + return id + } + + fun getRunningTargets(module: Module): List { + return hotReloadTargets.values + .filter { it.modulePackageName == module.packageName } + .map { it.toHookedProcess(module.versionCode) } + } + + fun hotReloadTarget(targetId: Long, module: Module, extras: android.os.Bundle?) { + val target = + hotReloadTargets[targetId] ?: throw SecurityException("Invalid hot reload target: $targetId") + if (target.modulePackageName != module.packageName) { + throw SecurityException("Target $targetId does not belong to ${module.packageName}") + } + if (target.state == HookedProcess.TARGET_STATE_RELOADING) { + throw IllegalStateException("Target $targetId is already reloading") + } + + target.state = HookedProcess.TARGET_STATE_RELOADING + runCatching { + target.target.hotReloadModule(module, extras) + target.loadedVersionCode = module.versionCode + target.state = HookedProcess.TARGET_STATE_UP_TO_DATE + } + .onFailure { + target.state = + if (target.target.asBinder().isBinderAlive) HookedProcess.TARGET_STATE_FAILED + else HookedProcess.TARGET_STATE_FAILED + throw it + } + } } 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..467243bd1 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 @@ -110,6 +112,9 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { override fun getFrameworkProperties(): Long { ensureModule() var prop = IXposedService.PROP_CAP_SYSTEM or IXposedService.PROP_CAP_REMOTE + if (loadedModule.file.moduleClassNames.size == 1) { + prop = prop or IXposedService.PROP_RT_HOT_RELOAD + } if (ConfigCache.state.isDexObfuscateEnabled) prop = prop or IXposedService.PROP_RT_API_PROTECTION return prop @@ -140,6 +145,37 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } } + override fun getRunningTargets(): List { + ensureModule() + return ApplicationService.getRunningTargets(loadedModule) + } + + override fun hotReloadModule( + targetId: Long, + data: Bundle?, + callback: IHotReloadCallback? + ) { + ensureModule() + runCatching { + if (loadedModule.file.moduleClassNames.size != 1) { + throw SecurityException("Hot reload requires exactly one Java entry class") + } + val latest = + ConfigCache.getModuleByPackage(loadedModule.packageName) + ?: throw SecurityException("Module ${loadedModule.packageName} is not enabled") + ApplicationService.hotReloadTarget(targetId, latest, data) + callback?.onHotReloadResult(IXposedService.HOT_RELOAD_SUCCESS, null) + } + .onFailure { throwable -> + val status = + when (throwable) { + is IllegalStateException -> IXposedService.HOT_RELOAD_IN_PROGRESS + else -> IXposedService.HOT_RELOAD_FAILED + } + callback?.onHotReloadResult(status, throwable.message) + } + } + override fun requestRemotePreferences(group: String): Bundle { val userId = ensureModule() return Bundle().apply { diff --git a/services/daemon-service/build.gradle.kts b/services/daemon-service/build.gradle.kts index e583a2570..8cb321be9 100644 --- a/services/daemon-service/build.gradle.kts +++ b/services/daemon-service/build.gradle.kts @@ -7,7 +7,7 @@ android { sourceSets { named("main") { - java.srcDirs("src/main/java", "../libxposed/service/src/main") + java.srcDirs("src/main/java", "../libxposed/service/src/main", "../../shared/libxposed-annotation/src/main/java") aidl.srcDirs("src/main/aidl", "../libxposed/interface/src/main/aidl") } } 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/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..d1dbe573c --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl @@ -0,0 +1,7 @@ +package org.lsposed.lspd.service; + +import org.lsposed.lspd.models.Module; + +interface IHotReloadTarget { + void hotReloadModule(in Module module, in Bundle extras); +} 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..55dd7dd4b 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,6 +1,7 @@ package org.lsposed.lspd.service; import org.lsposed.lspd.models.Module; +import org.lsposed.lspd.service.IHotReloadTarget; interface ILSPApplicationService { boolean isLogMuted(); @@ -12,4 +13,6 @@ interface ILSPApplicationService { String getPrefsPath(String packageName); ParcelFileDescriptor requestInjectedManagerBinder(out List binder); + + long registerHotReloadTarget(String modulePackageName, long loadedVersionCode, IHotReloadTarget target); } diff --git a/services/libxposed b/services/libxposed index 11f8945de..2ce494229 160000 --- a/services/libxposed +++ b/services/libxposed @@ -1 +1 @@ -Subproject commit 11f8945de4e24efc0eb0e2e87a2dd8284d8f7b66 +Subproject commit 2ce4942294a52587ac213748c5a93376fe3e1c3c diff --git a/shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/InternalApi.java b/shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/InternalApi.java new file mode 100644 index 000000000..817b942b1 --- /dev/null +++ b/shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/InternalApi.java @@ -0,0 +1,18 @@ +package io.github.libxposed.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Retention(RetentionPolicy.CLASS) +@Target({ + ElementType.TYPE, + ElementType.FIELD, + ElementType.METHOD, + ElementType.CONSTRUCTOR, +}) +public @interface InternalApi { +} diff --git a/shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/SinceApi.java b/shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/SinceApi.java new file mode 100644 index 000000000..2fb5f4456 --- /dev/null +++ b/shared/libxposed-annotation/src/main/java/io/github/libxposed/annotation/SinceApi.java @@ -0,0 +1,20 @@ +package io.github.libxposed.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Retention(RetentionPolicy.CLASS) +@Target({ + ElementType.TYPE, + ElementType.FIELD, + ElementType.METHOD, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER, +}) +public @interface SinceApi { + int value(); +} diff --git a/xposed/build.gradle.kts b/xposed/build.gradle.kts index 8edcc5cc4..269d50c0e 100644 --- a/xposed/build.gradle.kts +++ b/xposed/build.gradle.kts @@ -21,7 +21,7 @@ android { buildConfigField("long", "VERSION_CODE", versionCodeProvider.get()) } - sourceSets { named("main") { java.srcDirs("src/main/kotlin", "libxposed/api/src/main/java") } } + sourceSets { named("main") { java.srcDirs("src/main/kotlin", "libxposed/api/src/main/java", "../shared/libxposed-annotation/src/main/java") } } } dependencies { diff --git a/xposed/libxposed b/xposed/libxposed index edeb8379c..3a5ec7981 160000 --- a/xposed/libxposed +++ b/xposed/libxposed @@ -1 +1 @@ -Subproject commit edeb8379c067b16b91af3cb526f5f04db25c06b6 +Subproject commit 3a5ec7981db3f1b92ebddc78d85f823bd289f9a1 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..93592ba03 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(packageName, origin) } 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(packageName, clinit) } 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..d0f04231f 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt @@ -15,6 +15,10 @@ object VectorLifecycleManager { val activeModules: MutableSet = ConcurrentHashMap.newKeySet() + fun detach(module: XposedModule) { + activeModules.remove(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..c783797dd --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt @@ -0,0 +1,11 @@ +package org.matrix.vector.impl.core + +import android.os.Bundle +import org.lsposed.lspd.models.Module +import org.lsposed.lspd.service.IHotReloadTarget + +internal object VectorHotReloadTarget : IHotReloadTarget.Stub() { + override fun hotReloadModule(module: Module, extras: Bundle?) { + VectorModuleManager.hotReloadModule(module, extras) + } +} 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..a94e3b091 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,21 @@ package org.matrix.vector.impl.core import android.os.Build +import android.os.Bundle import android.os.Process import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedInterface +import io.github.libxposed.api.XposedModuleInterface.HotReloadedParam +import io.github.libxposed.api.XposedModuleInterface.HotReloadingParam import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam import java.io.File 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.freezeHooks +import org.matrix.vector.impl.hooks.getActiveHookHandles +import org.matrix.vector.impl.hooks.unfreezeHooks import org.matrix.vector.impl.utils.VectorModuleClassLoader import org.matrix.vector.nativebridge.NativeAPI @@ -19,6 +26,14 @@ import org.matrix.vector.nativebridge.NativeAPI object VectorModuleManager { private const val TAG = "VectorModuleManager" + private val moduleStates = java.util.concurrent.ConcurrentHashMap() + + private data class ModuleState( + val module: Module, + val processName: String, + val isSystemServer: Boolean, + val entries: List, + ) /** * Loads a module APK, instantiates its entry classes, and binds them to the Vector framework. @@ -27,15 +42,7 @@ object VectorModuleManager { try { Log.d(TAG, "Loading module ${module.packageName}") - // Construct the native library search path - val librarySearchPath = buildString { - val abis = - if (Process.is64Bit()) Build.SUPPORTED_64_BIT_ABIS - else Build.SUPPORTED_32_BIT_ABIS - for (abi in abis) { - append(module.apkPath).append("!/lib/").append(abi).append(File.pathSeparator) - } - } + val librarySearchPath = buildLibrarySearchPath(module) // Create the isolated ClassLoader for the module val initLoader = XposedModule::class.java.classLoader @@ -64,37 +71,24 @@ object VectorModuleManager { service = module.service, // Our IPC client ) - // Instantiate the module entry classes - for (className in module.file.moduleClassNames) { - runCatching { - val moduleClass = moduleClassLoader.loadClass(className) - Log.v(TAG, "Loading class $moduleClass") - - if (!XposedModule::class.java.isAssignableFrom(moduleClass)) { - Log.e(TAG, "Class does not extend XposedModule, skipping.") - return@runCatching - } - - val constructor = moduleClass.getDeclaredConstructor() - constructor.isAccessible = true - val moduleInstance = constructor.newInstance() as XposedModule - - // Attach the framework context to the module - moduleInstance.attachFramework(vectorContext) - - // Register the active module to receive future lifecycle events - VectorLifecycleManager.activeModules.add(moduleInstance) + val entries = instantiateEntries(module, moduleClassLoader, vectorContext) + entries.forEach { moduleInstance -> + VectorLifecycleManager.activeModules.add(moduleInstance) + moduleInstance.onModuleLoaded( + object : ModuleLoadedParam { + override fun isSystemServer(): Boolean = isSystemServer - // Trigger the initial onModuleLoaded callback - moduleInstance.onModuleLoaded( - object : ModuleLoadedParam { - override fun isSystemServer(): Boolean = isSystemServer - - override fun getProcessName(): String = processName - } - ) + override fun getProcessName(): String = processName } - .onFailure { e -> Log.e(TAG, "Failed to instantiate class $className", e) } + ) + } + moduleStates[module.packageName] = ModuleState(module, processName, isSystemServer, entries) + if (module.file.moduleClassNames.size == 1) { + VectorServiceClient.registerHotReloadTarget( + module.packageName, + module.versionCode, + VectorHotReloadTarget, + ) } // Register any native JNI entrypoints declared by the module @@ -109,4 +103,121 @@ object VectorModuleManager { return false } } + + @Synchronized + fun hotReloadModule(module: Module, extras: Bundle?) { + val oldState = + moduleStates[module.packageName] + ?: throw IllegalStateException("Module ${module.packageName} is not loaded") + if (module.file.moduleClassNames.size != 1) { + throw IllegalArgumentException("Hot reload requires exactly one Java entry class") + } + + var savedInstanceState: Any? = null + val reloadingParam = + object : HotReloadingParam { + override fun getExtras(): Bundle? = extras + + override fun setSavedInstanceState(outState: Any?) { + val loader = outState?.javaClass?.classLoader + if (loader != null && oldState.entries.any { it.javaClass.classLoader == loader }) { + throw IllegalArgumentException( + "Saved state must not be created by the old module classloader" + ) + } + savedInstanceState = outState + } + } + + val allowReload = + oldState.entries.fold(true) { allowed, entry -> + allowed && runCatching { entry.onHotReloading(reloadingParam) } + .onFailure { Log.e(TAG, "Error in onHotReloading for ${module.packageName}", it) } + .getOrDefault(false) + } + if (!allowReload) { + throw IllegalStateException("Module ${module.packageName} rejected hot reload") + } + + freezeHooks(module.packageName) + val oldHandles = getActiveHookHandles(module.packageName) + try { + oldState.entries.forEach(VectorLifecycleManager::detach) + + val librarySearchPath = buildLibrarySearchPath(module) + val moduleClassLoader = + VectorModuleClassLoader.loadApk( + module.apkPath, + module.file.preLoadedDexes, + librarySearchPath, + XposedModule::class.java.classLoader, + ) + val vectorContext = + VectorContext( + packageName = module.packageName, + applicationInfo = module.applicationInfo, + service = module.service, + ) + val newEntries = instantiateEntries(module, moduleClassLoader, vectorContext) + val param = + object : HotReloadedParam { + override fun isSystemServer(): Boolean = oldState.isSystemServer + + override fun getProcessName(): String = oldState.processName + + override fun getExtras(): Bundle? = extras + + override fun getSavedInstanceState(): Any? = savedInstanceState + + override fun getOldHookHandles(): List = oldHandles + } + newEntries.forEach { entry -> + VectorLifecycleManager.activeModules.add(entry) + entry.onHotReloaded(param) + } + moduleStates[module.packageName] = + ModuleState(module, oldState.processName, oldState.isSystemServer, newEntries) + } finally { + unfreezeHooks(module.packageName) + } + } + + private fun buildLibrarySearchPath(module: Module): String = buildString { + val abis = + if (Process.is64Bit()) Build.SUPPORTED_64_BIT_ABIS + else Build.SUPPORTED_32_BIT_ABIS + for (abi in abis) { + append(module.apkPath).append("!/lib/").append(abi).append(File.pathSeparator) + } + } + + private fun instantiateEntries( + module: Module, + moduleClassLoader: ClassLoader, + vectorContext: VectorContext, + ): List { + val entries = mutableListOf() + for (className in module.file.moduleClassNames) { + runCatching { + val moduleClass = moduleClassLoader.loadClass(className) + Log.v(TAG, "Loading class $moduleClass") + + if (!XposedModule::class.java.isAssignableFrom(moduleClass)) { + Log.e(TAG, "Class does not extend XposedModule, skipping.") + return@runCatching + } + + val constructor = moduleClass.getDeclaredConstructor() + constructor.isAccessible = true + val moduleInstance = constructor.newInstance() as XposedModule + moduleInstance.attachFramework(vectorContext) { + VectorLifecycleManager.detach(moduleInstance) + } + entries += moduleInstance + } + .onFailure { e -> Log.e(TAG, "Failed to instantiate class $className", e) } + } + return entries + } + } 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..156cc8dba 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 @@ -4,6 +4,7 @@ import android.os.IBinder import android.os.ParcelFileDescriptor import org.lsposed.lspd.models.Module import org.lsposed.lspd.service.ILSPApplicationService +import org.lsposed.lspd.service.IHotReloadTarget import org.lsposed.lspd.util.Utils.Log /** @@ -54,6 +55,17 @@ object VectorServiceClient : ILSPApplicationService, IBinder.DeathRecipient { return runCatching { service?.requestInjectedManagerBinder(binder) }.getOrNull() } + override fun registerHotReloadTarget( + modulePackageName: String, + loadedVersionCode: Long, + target: IHotReloadTarget, + ): Long { + return runCatching { + service?.registerHotReloadTarget(modulePackageName, loadedVersionCode, target) + } + .getOrNull() ?: -1L + } + override fun asBinder(): IBinder? { return service?.asBinder() } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt index c800fd89a..7aee6c712 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/BaseInvoker.kt @@ -45,7 +45,9 @@ internal abstract class BaseInvoker, U : Executable>( // Filter hooks to respect the maxPriority requested by the module val filteredHooks = - allModernHooks.filter { it.priority <= currentType.maxPriority }.toTypedArray() + allModernHooks + .filter { it.isActive() && it.priority <= currentType.maxPriority } + .toTypedArray() val terminal: (Any?, Array) -> Any? = { tObj, tArgs -> val delegate = VectorBootstrap.delegate 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..e6ace2d2f 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt @@ -4,14 +4,24 @@ import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedInterface.Chain import io.github.libxposed.api.XposedInterface.ExceptionMode import java.lang.reflect.Executable +import java.util.concurrent.atomic.AtomicBoolean import org.lsposed.lspd.util.Utils /** Represents a registered hook configuration, stored natively by [HookBridge]. */ -data class VectorHookRecord( - val hooker: XposedInterface.Hooker, +class VectorHookRecord( + val modulePackageName: String, + val executable: Executable, + val id: String?, val priority: Int, + val hooker: XposedInterface.Hooker, val exceptionMode: ExceptionMode, -) +) { + private val active = AtomicBoolean(true) + + fun isActive(): Boolean = active.get() + + fun deactivate(): Boolean = active.compareAndSet(true, false) +} /** * Core interceptor chain engine. Manages recursive hook execution and enforces [ExceptionMode] @@ -59,9 +69,12 @@ class VectorChain( return executeDownstream { terminal(thisObject, currentArgs) } } - val record = hooks[hookIndex] val nextChain = VectorChain(executable, thisObject, currentArgs, hooks, hookIndex + 1, terminal) + val record = hooks[hookIndex] + if (!record.isActive()) { + return nextChain.internalProceed(thisObject, currentArgs) + } return try { executeDownstream { record.hooker.intercept(nextChain) } 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..60872ad11 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,20 @@ 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 { +class VectorHookBuilder(private val modulePackageName: String, private val origin: Executable) : + HookBuilder { + + constructor(origin: Executable) : this(FRAMEWORK_HOOK_OWNER, origin) 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,7 +31,44 @@ 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 { + validateHookTarget() + if (HookRegistry.isFrozen(modulePackageName)) { + throw IllegalStateException("Module $modulePackageName is frozen for hot reload") + } + + val hookKey = id?.let { HookKey(modulePackageName, origin, it) } + val record = createRecord(hooker) + + if (hookKey != null) { + synchronized(HookRegistry) { + val existing = HookRegistry.records[hookKey] + installRecord(record) + HookRegistry.records[hookKey] = record + if (existing != null) { + uninstallRecord(existing) + } + return VectorHookHandle(record, hookKey) + } + } + + installRecord(record) + return VectorHookHandle(record, null) + } + + private fun createRecord(hooker: Hooker): VectorHookRecord = + VectorHookRecord( + modulePackageName = modulePackageName, + executable = origin, + id = id, + priority = priority, + hooker = hooker, + exceptionMode = exceptionMode, + ) + + private fun validateHookTarget() { if (Modifier.isAbstract(origin.modifiers)) { throw IllegalArgumentException("Cannot hook abstract methods: $origin") } else if (origin.declaringClass.classLoader == VectorHookBuilder::class.java.classLoader) { @@ -38,26 +80,112 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder { ) { throw IllegalArgumentException("Cannot hook Method.invoke") } + } +} - val record = VectorHookRecord(hooker, priority, exceptionMode) +private const val FRAMEWORK_HOOK_OWNER = "org.matrix.vector.framework" - // Register natively. HookBridge now stores VectorHookRecord instead of HookerCallback. - if ( - !HookBridge.hookMethod(true, origin, VectorNativeHooker::class.java, priority, record) - ) { - throw HookFailedError("Cannot hook $origin") +private data class HookKey( + val modulePackageName: String, + val executable: Executable, + val id: String, +) + +private object HookRegistry { + val records = ConcurrentHashMap() + private val frozenModules = ConcurrentHashMap.newKeySet() + + fun freeze(modulePackageName: String) { + frozenModules.add(modulePackageName) + } + + fun unfreeze(modulePackageName: String) { + frozenModules.remove(modulePackageName) + } + + fun isFrozen(modulePackageName: String): Boolean { + return frozenModules.contains(modulePackageName) + } + + fun handlesForModule(modulePackageName: String): List { + return records.values + .filter { it.modulePackageName == modulePackageName && it.isActive() } + .map { VectorHookHandle(it, it.id?.let { id -> HookKey(modulePackageName, it.executable, id) }) } + } +} + +internal fun getActiveHookHandles(modulePackageName: String): List { + return HookRegistry.handlesForModule(modulePackageName) +} + +internal fun freezeHooks(modulePackageName: String) { + HookRegistry.freeze(modulePackageName) +} + +internal fun unfreezeHooks(modulePackageName: String) { + HookRegistry.unfreeze(modulePackageName) +} + +private class VectorHookHandle( + private val record: VectorHookRecord, + private val hookKey: HookKey?, +) : HookHandle { + override fun getExecutable(): Executable = record.executable + + override fun getId(): String? = record.id + + override fun unhook() { + if (uninstallRecord(record)) { + hookKey?.let { key -> HookRegistry.records.remove(key, record) } } + } - return object : HookHandle { - override fun getExecutable(): Executable = origin + override fun replaceHook(hooker: Hooker): HookHandle { + if (!record.isActive()) { + throw IllegalStateException("Hook handle is no longer valid") + } + val replacement = + VectorHookRecord( + modulePackageName = record.modulePackageName, + executable = record.executable, + id = record.id, + priority = record.priority, + hooker = hooker, + exceptionMode = record.exceptionMode, + ) - override fun unhook() { - HookBridge.unhookMethod(true, origin, record) + synchronized(HookRegistry) { + if (!record.isActive()) { + throw IllegalStateException("Hook handle is no longer valid") } + installRecord(replacement) + hookKey?.let { key -> HookRegistry.records[key] = replacement } + uninstallRecord(record) } + return VectorHookHandle(replacement, hookKey) } } +private fun installRecord(record: VectorHookRecord) { + if ( + !HookBridge.hookMethod( + true, + record.executable, + VectorNativeHooker::class.java, + record.priority, + record, + ) + ) { + throw HookFailedError("Cannot hook ${record.executable}") + } +} + +private fun uninstallRecord(record: VectorHookRecord): Boolean { + if (!record.deactivate()) return false + HookBridge.unhookMethod(true, record.executable, record) + return true +} + /** * The native callback entrypoint. Instantiated natively by [HookBridge] when a hooked method is * hit. @@ -75,7 +203,9 @@ class VectorNativeHooker(private val method: T) { // Retrieve the hook snapshots val snapshots = HookBridge.callbackSnapshot(VectorHookRecord::class.java, method) - @Suppress("UNCHECKED_CAST") val modernHooks = snapshots[0] as Array + @Suppress("UNCHECKED_CAST") + val modernHooks = + (snapshots[0] as Array).filter { it.isActive() }.toTypedArray() val legacyHooks = snapshots[1] // Fast path: No hooks active From f3438a259bf2776470e78fc482eafa0ae4a2b02a Mon Sep 17 00:00:00 2001 From: NkBe Date: Fri, 29 May 2026 23:23:47 +0800 Subject: [PATCH 2/2] Fix duplicate annotation classes in zygisk dex merge --- services/daemon-service/build.gradle.kts | 3 ++- settings.gradle.kts | 1 + shared/libxposed-annotation/build.gradle.kts | 6 ++++++ xposed/build.gradle.kts | 3 ++- 4 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 shared/libxposed-annotation/build.gradle.kts diff --git a/services/daemon-service/build.gradle.kts b/services/daemon-service/build.gradle.kts index 8cb321be9..33c96da35 100644 --- a/services/daemon-service/build.gradle.kts +++ b/services/daemon-service/build.gradle.kts @@ -7,7 +7,7 @@ android { sourceSets { named("main") { - java.srcDirs("src/main/java", "../libxposed/service/src/main", "../../shared/libxposed-annotation/src/main/java") + java.srcDirs("src/main/java", "../libxposed/service/src/main") aidl.srcDirs("src/main/aidl", "../libxposed/interface/src/main/aidl") } } @@ -18,5 +18,6 @@ android { dependencies { compileOnly(libs.androidx.annotation) + compileOnly(projects.shared.libxposedAnnotation) compileOnly(projects.hiddenapi.stubs) } diff --git a/settings.gradle.kts b/settings.gradle.kts index c623fd46f..de7b5fa97 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,6 +27,7 @@ include( ":hiddenapi:stubs", ":hiddenapi:bridge", ":legacy", + ":shared:libxposed-annotation", ":services:manager-service", ":services:daemon-service", ":xposed", diff --git a/shared/libxposed-annotation/build.gradle.kts b/shared/libxposed-annotation/build.gradle.kts new file mode 100644 index 000000000..846fcebaf --- /dev/null +++ b/shared/libxposed-annotation/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { `java-library` } + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} diff --git a/xposed/build.gradle.kts b/xposed/build.gradle.kts index 269d50c0e..5aec46ee0 100644 --- a/xposed/build.gradle.kts +++ b/xposed/build.gradle.kts @@ -21,7 +21,7 @@ android { buildConfigField("long", "VERSION_CODE", versionCodeProvider.get()) } - sourceSets { named("main") { java.srcDirs("src/main/kotlin", "libxposed/api/src/main/java", "../shared/libxposed-annotation/src/main/java") } } + sourceSets { named("main") { java.srcDirs("src/main/kotlin", "libxposed/api/src/main/java") } } } dependencies { @@ -29,5 +29,6 @@ dependencies { implementation(projects.hiddenapi.bridge) implementation(projects.services.daemonService) compileOnly(libs.androidx.annotation) + compileOnly(projects.shared.libxposedAnnotation) compileOnly(projects.hiddenapi.stubs) }