From df2c1fa589fed3480de02b62bb189a69a623cb89 Mon Sep 17 00:00:00 2001 From: NkBe Date: Fri, 29 May 2026 22:38:54 +0800 Subject: [PATCH 1/4] 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/4] 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) } From 7cc67f804f4783536e768285f4d51f47850dd07a Mon Sep 17 00:00:00 2001 From: NkBe Date: Mon, 8 Jun 2026 23:58:09 +0800 Subject: [PATCH 3/4] Fix API102 hot reload handoff --- .../vector/impl/core/VectorModuleManager.kt | 17 +++++++----- .../vector/impl/hooks/VectorNativeHooker.kt | 26 ++++++++++++------- 2 files changed, 27 insertions(+), 16 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 a94e3b091..abb735466 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 @@ -74,13 +74,18 @@ object VectorModuleManager { val entries = instantiateEntries(module, moduleClassLoader, vectorContext) entries.forEach { moduleInstance -> VectorLifecycleManager.activeModules.add(moduleInstance) - moduleInstance.onModuleLoaded( - object : ModuleLoadedParam { - override fun isSystemServer(): Boolean = isSystemServer + runCatching { + moduleInstance.onModuleLoaded( + object : ModuleLoadedParam { + override fun isSystemServer(): Boolean = isSystemServer - override fun getProcessName(): String = processName + override fun getProcessName(): String = processName + } + ) + } + .onFailure { + Log.e(TAG, "Error in onModuleLoaded for ${module.packageName}", it) } - ) } moduleStates[module.packageName] = ModuleState(module, processName, isSystemServer, entries) if (module.file.moduleClassNames.size == 1) { @@ -139,7 +144,7 @@ object VectorModuleManager { throw IllegalStateException("Module ${module.packageName} rejected hot reload") } - freezeHooks(module.packageName) + freezeHooks(module.packageName, oldState.entries.mapNotNull { it.javaClass.classLoader }) val oldHandles = getActiveHookHandles(module.packageName) try { oldState.entries.forEach(VectorLifecycleManager::detach) 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 60872ad11..26172e9fc 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 @@ -35,7 +35,7 @@ class VectorHookBuilder(private val modulePackageName: String, private val origi override fun intercept(hooker: Hooker): HookHandle { validateHookTarget() - if (HookRegistry.isFrozen(modulePackageName)) { + if (HookRegistry.isFrozen(modulePackageName, hooker)) { throw IllegalStateException("Module $modulePackageName is frozen for hot reload") } @@ -93,22 +93,26 @@ private data class HookKey( private object HookRegistry { val records = ConcurrentHashMap() - private val frozenModules = ConcurrentHashMap.newKeySet() + val allRecords = ConcurrentHashMap.newKeySet() + private val frozenLoaders = ConcurrentHashMap>() - fun freeze(modulePackageName: String) { - frozenModules.add(modulePackageName) + fun freeze(modulePackageName: String, classLoaders: Collection) { + frozenLoaders[modulePackageName] = ConcurrentHashMap.newKeySet().apply { + addAll(classLoaders) + } } fun unfreeze(modulePackageName: String) { - frozenModules.remove(modulePackageName) + frozenLoaders.remove(modulePackageName) } - fun isFrozen(modulePackageName: String): Boolean { - return frozenModules.contains(modulePackageName) + fun isFrozen(modulePackageName: String, hooker: Hooker): Boolean { + val classLoader = hooker.javaClass.classLoader ?: return false + return frozenLoaders[modulePackageName]?.contains(classLoader) == true } fun handlesForModule(modulePackageName: String): List { - return records.values + return allRecords .filter { it.modulePackageName == modulePackageName && it.isActive() } .map { VectorHookHandle(it, it.id?.let { id -> HookKey(modulePackageName, it.executable, id) }) } } @@ -118,8 +122,8 @@ internal fun getActiveHookHandles(modulePackageName: String): List { return HookRegistry.handlesForModule(modulePackageName) } -internal fun freezeHooks(modulePackageName: String) { - HookRegistry.freeze(modulePackageName) +internal fun freezeHooks(modulePackageName: String, classLoaders: Collection) { + HookRegistry.freeze(modulePackageName, classLoaders) } internal fun unfreezeHooks(modulePackageName: String) { @@ -178,11 +182,13 @@ private fun installRecord(record: VectorHookRecord) { ) { throw HookFailedError("Cannot hook ${record.executable}") } + HookRegistry.allRecords.add(record) } private fun uninstallRecord(record: VectorHookRecord): Boolean { if (!record.deactivate()) return false HookBridge.unhookMethod(true, record.executable, record) + HookRegistry.allRecords.remove(record) return true } From 3b195ce560345267e8acb86d59c44e01850bce02 Mon Sep 17 00:00:00 2001 From: NkBe Date: Sun, 14 Jun 2026 17:26:09 +0800 Subject: [PATCH 4/4] Update hot reload finalization The final libxposed API 102 hot reload contract makes the old generation responsible for retiring its own Java and native resources before returning true from onHotReloading. The framework also does not call UnregisterNatives, JNI_OnUnload, or dlclose during hot reload, so it must avoid keeping stale framework-owned references after the new generation is handed off. This updates the libxposed submodule pointers to the finalized API 102 revisions and aligns Vector's implementation with that contract. onHotReloading exceptions now remain diagnostic failures instead of being collapsed into a false rejection, saved state is checked for nested old-classloader objects, old entries are detached after the new state is committed, and failed pre-commit reload attempts clean up only newly-created hooks while preserving the old hook handles. The daemon-side service status mapping is also updated for the final service API: the removed PROP_RT_HOT_RELOAD bit is no longer advertised, HOT_RELOAD_SUCCEEDED is used instead of the draft HOT_RELOAD_SUCCESS name, unsupported targets report HOT_RELOAD_UNSUPPORTED, concurrent reloads report HOT_RELOAD_IN_PROGRESS, and dead target processes report HOT_RELOAD_PROCESS_DIED. Documentation References: libxposed XposedModuleInterface hot reload callbacks: https://libxposed.github.io/api/io/github/libxposed/api/XposedModuleInterface.html#onHotReloaded(io.github.libxposed.api.XposedModuleInterface.HotReloadedParam) --- .../vector/daemon/ipc/ApplicationService.kt | 16 +++- .../matrix/vector/daemon/ipc/ModuleService.kt | 19 ++-- services/libxposed | 2 +- xposed/libxposed | 2 +- .../vector/impl/core/VectorModuleManager.kt | 88 +++++++++++++++---- .../vector/impl/hooks/VectorNativeHooker.kt | 15 +++- zygisk/proguard-rules.pro | 2 + 7 files changed, 115 insertions(+), 29 deletions(-) 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 7898d5502..438c3c14c 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 @@ -27,6 +27,12 @@ const val DEX_TRANSACTION_CODE = const val OBFUSCATION_MAP_TRANSACTION_CODE = ('_'.code shl 24) or ('O'.code shl 16) or ('B'.code shl 8) or 'F'.code +internal class HotReloadInProgressException(message: String) : IllegalStateException(message) + +internal class HotReloadProcessDiedException(message: String) : IllegalStateException(message) + +internal class HotReloadUnsupportedException(message: String) : IllegalStateException(message) + object ApplicationService : ILSPApplicationService.Stub() { data class ProcessKey(val uid: Int, val pid: Int) @@ -217,7 +223,7 @@ object ApplicationService : ILSPApplicationService.Stub() { throw SecurityException("Target $targetId does not belong to ${module.packageName}") } if (target.state == HookedProcess.TARGET_STATE_RELOADING) { - throw IllegalStateException("Target $targetId is already reloading") + throw HotReloadInProgressException("Target $targetId is already reloading") } target.state = HookedProcess.TARGET_STATE_RELOADING @@ -227,9 +233,11 @@ object ApplicationService : ILSPApplicationService.Stub() { 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 + if (!target.target.asBinder().isBinderAlive) { + hotReloadTargets.remove(target.id, target) + throw HotReloadProcessDiedException("Target process died before hot reload completed") + } + target.state = 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 467243bd1..9c8b6eb4d 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 @@ -112,9 +112,6 @@ 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 @@ -158,18 +155,26 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { ensureModule() runCatching { if (loadedModule.file.moduleClassNames.size != 1) { - throw SecurityException("Hot reload requires exactly one Java entry class") + throw HotReloadUnsupportedException("Hot reload requires exactly one Java entry class") } val latest = ConfigCache.getModuleByPackage(loadedModule.packageName) - ?: throw SecurityException("Module ${loadedModule.packageName} is not enabled") + ?: throw HotReloadUnsupportedException( + "Module ${loadedModule.packageName} is not enabled") + if (latest.file.moduleClassNames.size != 1) { + throw HotReloadUnsupportedException( + "Hot reload requires exactly one Java entry class") + } ApplicationService.hotReloadTarget(targetId, latest, data) - callback?.onHotReloadResult(IXposedService.HOT_RELOAD_SUCCESS, null) + callback?.onHotReloadResult(IXposedService.HOT_RELOAD_SUCCEEDED, null) } .onFailure { throwable -> + if (throwable is SecurityException) throw throwable val status = when (throwable) { - is IllegalStateException -> IXposedService.HOT_RELOAD_IN_PROGRESS + is HotReloadInProgressException -> IXposedService.HOT_RELOAD_IN_PROGRESS + is HotReloadProcessDiedException -> IXposedService.HOT_RELOAD_PROCESS_DIED + is HotReloadUnsupportedException -> IXposedService.HOT_RELOAD_UNSUPPORTED else -> IXposedService.HOT_RELOAD_FAILED } callback?.onHotReloadResult(status, throwable.message) diff --git a/services/libxposed b/services/libxposed index 2ce494229..331894087 160000 --- a/services/libxposed +++ b/services/libxposed @@ -1 +1 @@ -Subproject commit 2ce4942294a52587ac213748c5a93376fe3e1c3c +Subproject commit 3318940876192e29cf6ab07637e899e22a87ebf0 diff --git a/xposed/libxposed b/xposed/libxposed index 3a5ec7981..45e7c5cfe 160000 --- a/xposed/libxposed +++ b/xposed/libxposed @@ -1 +1 @@ -Subproject commit 3a5ec7981db3f1b92ebddc78d85f823bd289f9a1 +Subproject commit 45e7c5cfe54725b6d828d8b7be65e22ce60c67e4 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 abb735466..b588d863d 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 @@ -9,12 +9,16 @@ 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 java.lang.reflect.Array +import java.util.Collections +import java.util.IdentityHashMap 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.unhookAllModuleHooks import org.matrix.vector.impl.hooks.unfreezeHooks import org.matrix.vector.impl.utils.VectorModuleClassLoader import org.matrix.vector.nativebridge.NativeAPI @@ -118,14 +122,15 @@ object VectorModuleManager { throw IllegalArgumentException("Hot reload requires exactly one Java entry class") } + val oldClassLoaders = + oldState.entries.mapNotNullTo(mutableSetOf()) { it.javaClass.classLoader } 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 }) { + if (containsOldClassLoaderObject(outState, oldClassLoaders)) { throw IllegalArgumentException( "Saved state must not be created by the old module classloader" ) @@ -134,21 +139,24 @@ object VectorModuleManager { } } - val allowReload = - oldState.entries.fold(true) { allowed, entry -> - allowed && runCatching { entry.onHotReloading(reloadingParam) } + var allowReload = true + oldState.entries.forEach { entry -> + if (!allowReload) return@forEach + allowReload = + runCatching { entry.onHotReloading(reloadingParam) } .onFailure { Log.e(TAG, "Error in onHotReloading for ${module.packageName}", it) } - .getOrDefault(false) - } + .getOrThrow() + } if (!allowReload) { - throw IllegalStateException("Module ${module.packageName} rejected hot reload") + Log.d(TAG, "Module ${module.packageName} rejected hot reload") + throw IllegalStateException() } - freezeHooks(module.packageName, oldState.entries.mapNotNull { it.javaClass.classLoader }) + freezeHooks(module.packageName, oldClassLoaders) val oldHandles = getActiveHookHandles(module.packageName) + var newStateCommitted = false + var newEntries: List = emptyList() try { - oldState.entries.forEach(VectorLifecycleManager::detach) - val librarySearchPath = buildLibrarySearchPath(module) val moduleClassLoader = VectorModuleClassLoader.loadApk( @@ -163,7 +171,11 @@ object VectorModuleManager { applicationInfo = module.applicationInfo, service = module.service, ) - val newEntries = instantiateEntries(module, moduleClassLoader, vectorContext) + newEntries = instantiateEntries(module, moduleClassLoader, vectorContext) + if (newEntries.size != module.file.moduleClassNames.size) { + throw IllegalStateException("Failed to instantiate hot reload entry") + } + val param = object : HotReloadedParam { override fun isSystemServer(): Boolean = oldState.isSystemServer @@ -176,13 +188,24 @@ object VectorModuleManager { override fun getOldHookHandles(): List = oldHandles } + moduleStates[module.packageName] = + ModuleState(module, oldState.processName, oldState.isSystemServer, newEntries) + newStateCommitted = true + // Keep oldState strongly reachable until callbacks finish, but stop lifecycle dispatch. + oldState.entries.forEach(VectorLifecycleManager::detach) newEntries.forEach { entry -> VectorLifecycleManager.activeModules.add(entry) - entry.onHotReloaded(param) + runCatching { entry.onHotReloaded(param) } + .onFailure { Log.e(TAG, "Error in onHotReloaded for ${module.packageName}", it) } + .getOrThrow() } - moduleStates[module.packageName] = - ModuleState(module, oldState.processName, oldState.isSystemServer, newEntries) } finally { + if (newStateCommitted) { + oldState.entries.forEach(VectorLifecycleManager::detach) + } else { + newEntries.forEach(VectorLifecycleManager::detach) + unhookAllModuleHooks(module.packageName, oldHandles.toSet()) + } unfreezeHooks(module.packageName) } } @@ -225,4 +248,39 @@ object VectorModuleManager { return entries } + @Suppress("DEPRECATION") + private fun containsOldClassLoaderObject( + value: Any?, + oldClassLoaders: Set, + seen: MutableSet = Collections.newSetFromMap(IdentityHashMap()), + ): Boolean { + if (value == null || !seen.add(value)) return false + if (value is ClassLoader && value in oldClassLoaders) return true + if (value is Class<*> && value.classLoader in oldClassLoaders) return true + if (value.javaClass.classLoader in oldClassLoaders) return true + if (value is Bundle) { + return value.keySet().any { key -> + runCatching { containsOldClassLoaderObject(value.get(key), oldClassLoaders, seen) } + .getOrDefault(true) + } + } + if (value is Map<*, *>) { + return value.entries.any { + containsOldClassLoaderObject(it.key, oldClassLoaders, seen) || + containsOldClassLoaderObject(it.value, oldClassLoaders, seen) + } + } + if (value is Iterable<*>) { + return value.any { containsOldClassLoaderObject(it, oldClassLoaders, seen) } + } + if (value.javaClass.isArray) { + for (index in 0 until Array.getLength(value)) { + if (containsOldClassLoaderObject(Array.get(value, index), oldClassLoaders, seen)) { + return true + } + } + } + return false + } + } 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 26172e9fc..1332887d2 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 @@ -130,8 +130,18 @@ internal fun unfreezeHooks(modulePackageName: String) { HookRegistry.unfreeze(modulePackageName) } +internal fun unhookAllModuleHooks( + modulePackageName: String, + except: Set = emptySet(), +) { + val excludedRecords = except.mapNotNull { (it as? VectorHookHandle)?.record }.toSet() + HookRegistry.allRecords + .filter { it.modulePackageName == modulePackageName && it !in excludedRecords } + .forEach(::uninstallRecord) +} + private class VectorHookHandle( - private val record: VectorHookRecord, + val record: VectorHookRecord, private val hookKey: HookKey?, ) : HookHandle { override fun getExecutable(): Executable = record.executable @@ -188,6 +198,9 @@ private fun installRecord(record: VectorHookRecord) { private fun uninstallRecord(record: VectorHookRecord): Boolean { if (!record.deactivate()) return false HookBridge.unhookMethod(true, record.executable, record) + record.id?.let { id -> + HookRegistry.records.remove(HookKey(record.modulePackageName, record.executable, id), record) + } HookRegistry.allRecords.remove(record) return true } diff --git a/zygisk/proguard-rules.pro b/zygisk/proguard-rules.pro index b8166bd58..e68739e6a 100644 --- a/zygisk/proguard-rules.pro +++ b/zygisk/proguard-rules.pro @@ -7,5 +7,7 @@ -keepclasseswithmembers class org.matrix.vector.service.BridgeService { public static boolean *(android.os.IBinder, int, long, long, int); } + +-dontwarn io.github.libxposed.annotation.** -repackageclasses -allowaccessmodification