moduleLibraryNames;
boolean legacy;
+ int targetApiVersion;
+ int minApiVersion;
+ boolean autoHotReload;
}
diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl
new file mode 100644
index 000000000..35d34e767
--- /dev/null
+++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl
@@ -0,0 +1,20 @@
+package org.lsposed.lspd.service;
+
+import org.lsposed.lspd.models.HotReloadOutcome;
+import org.lsposed.lspd.models.Module;
+
+/**
+ * Daemon-to-process entry point for hot reloading a module generation in place.
+ *
+ * Registered once per process while the framework bootstraps, before any module is loaded, so
+ * that a target exists regardless of when the daemon's module cache becomes available.
+ */
+interface IHotReloadTarget {
+ /**
+ * Replaces the loaded generation of modulePackageName with newModule.
+ *
+ * Runs the old code's onHotReloading and the new code's onHotReloaded, and blocks for their
+ * duration; the daemon calls this off the binder thread that served the module app.
+ */
+ HotReloadOutcome hotReload(String modulePackageName, in Bundle extras, in Module newModule) = 1;
+}
diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl
index b85b6ed21..c8fa3c8cb 100644
--- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl
+++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl
@@ -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,14 @@ interface ILSPApplicationService {
String getPrefsPath(String packageName);
ParcelFileDescriptor requestInjectedManagerBinder(out List binder);
+
+ /**
+ * Registers this process's hot reload entry point. Called once while the framework bootstraps,
+ * independently of module loading, so that system_server - whose modules are loaded before the
+ * daemon's module cache exists - is a reloadable target like any other process.
+ *
+ * Appended rather than inserted: this interface leaves transaction ids implicit, so adding a
+ * method anywhere above would renumber every method after it.
+ */
+ void registerHotReloadTarget(IHotReloadTarget target);
}
diff --git a/services/libxposed b/services/libxposed
index 11f8945de..331894087 160000
--- a/services/libxposed
+++ b/services/libxposed
@@ -1 +1 @@
-Subproject commit 11f8945de4e24efc0eb0e2e87a2dd8284d8f7b66
+Subproject commit 3318940876192e29cf6ab07637e899e22a87ebf0
diff --git a/xposed/build.gradle.kts b/xposed/build.gradle.kts
index ef5cee53c..ad1afb277 100644
--- a/xposed/build.gradle.kts
+++ b/xposed/build.gradle.kts
@@ -28,5 +28,6 @@ dependencies {
implementation(projects.hiddenapi.bridge)
implementation(projects.services.daemonService)
compileOnly(libs.androidx.annotation)
+ compileOnly(libs.libxposed.annotation)
compileOnly(projects.hiddenapi.stubs)
}
diff --git a/xposed/consumer-rules.pro b/xposed/consumer-rules.pro
index d18731d55..91fd7f386 100644
--- a/xposed/consumer-rules.pro
+++ b/xposed/consumer-rules.pro
@@ -1,6 +1,9 @@
# Preserve the libxposed public API surface for module developers
-keep class io.github.libxposed.** { *; }
+# The libxposed annotations are compile-only metadata and are not packaged
+-dontwarn io.github.libxposed.annotation.**
+
# Preserve all native methods (HookBridge, ResourcesHook, NativeAPI, etc.)
-keepclasseswithmembers,includedescriptorclasses class * {
native ;
diff --git a/xposed/libxposed b/xposed/libxposed
index edeb8379c..45e7c5cfe 160000
--- a/xposed/libxposed
+++ b/xposed/libxposed
@@ -1 +1 @@
-Subproject commit edeb8379c067b16b91af3cb526f5f04db25c06b6
+Subproject commit 45e7c5cfe54725b6d828d8b7be65e22ce60c67e4
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt
index d92faabb6..b7c0425cd 100644
--- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt
+++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt
@@ -29,6 +29,18 @@ class VectorContext(
private val remotePrefs = ConcurrentHashMap()
+ // Keyed on the generation, not the hooker's classloader, which says nothing about who
+ // registered a hook.
+ @Volatile private var frozen = false
+
+ fun freeze() {
+ frozen = true
+ }
+
+ fun unfreeze() {
+ frozen = false
+ }
+
override fun getFrameworkName(): String = BuildConfig.FRAMEWORK_NAME
override fun getFrameworkVersion(): String = BuildConfig.VERSION_NAME
@@ -40,14 +52,14 @@ class VectorContext(
}
override fun hook(origin: Executable): XposedInterface.HookBuilder {
- return VectorHookBuilder(origin)
+ return VectorHookBuilder(origin, packageName) { frozen }
}
override fun hookClassInitializer(origin: Class<*>): XposedInterface.HookBuilder {
val clinit =
HookBridge.getStaticInitializer(origin)
?: throw IllegalArgumentException("Class ${origin.name} has no static initializer")
- return VectorHookBuilder(clinit)
+ return VectorHookBuilder(clinit, packageName) { frozen }
}
override fun deoptimize(executable: Executable): Boolean {
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt
index e1d73ac92..8ef69540e 100644
--- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt
+++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorLifecycleManager.kt
@@ -13,8 +13,18 @@ object VectorLifecycleManager {
private const val TAG = "VectorLifecycle"
+ // The framework's only strong reference to entry instances, and what detach() removes. Any
+ // dispatch added later, hot reload included, must iterate this rather than keep its own list.
val activeModules: MutableSet = ConcurrentHashMap.newKeySet()
+ fun detach(module: XposedModule) {
+ if (activeModules.remove(module)) {
+ Log.d(TAG, "Detached entry ${module.javaClass.name}")
+ }
+ }
+
+ fun isActive(module: XposedModule): Boolean = activeModules.contains(module)
+
fun dispatchPackageLoaded(
packageName: String,
appInfo: ApplicationInfo,
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt
new file mode 100644
index 000000000..567b16d36
--- /dev/null
+++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt
@@ -0,0 +1,16 @@
+package org.matrix.vector.impl.core
+
+import android.os.Bundle
+import org.lsposed.lspd.models.HotReloadOutcome
+import org.lsposed.lspd.models.Module
+import org.lsposed.lspd.service.IHotReloadTarget
+
+/** Registered once while the framework bootstraps, before any module is loaded. */
+object VectorHotReloadTarget : IHotReloadTarget.Stub() {
+
+ override fun hotReload(
+ modulePackageName: String?,
+ extras: Bundle?,
+ newModule: Module?,
+ ): HotReloadOutcome = VectorModuleManager.hotReload(modulePackageName, extras, newModule)
+}
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt
index a5afba016..f5375e53b 100644
--- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt
+++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt
@@ -1,14 +1,24 @@
package org.matrix.vector.impl.core
import android.os.Build
+import android.os.Bundle
import android.os.Process
+import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedModule
+import io.github.libxposed.api.XposedModuleInterface.HotReloadedParam
+import io.github.libxposed.api.XposedModuleInterface.HotReloadingParam
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
+import io.github.libxposed.service.IXposedService
import java.io.File
+import java.lang.ref.WeakReference
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.locks.ReentrantLock
+import org.lsposed.lspd.models.HotReloadOutcome
import org.lsposed.lspd.models.Module
import org.lsposed.lspd.util.Utils.Log
import org.matrix.vector.impl.VectorContext
import org.matrix.vector.impl.VectorLifecycleManager
+import org.matrix.vector.impl.hooks.VectorHookBuilder
import org.matrix.vector.impl.utils.VectorModuleClassLoader
import org.matrix.vector.nativebridge.NativeAPI
@@ -20,10 +30,64 @@ object VectorModuleManager {
private const val TAG = "VectorModuleManager"
+ // Entries are weak on purpose: activeModules owns the only strong reference, and detach()
+ // removes it. A reload holds a local strong list for the cycle instead.
+ private class Generation(
+ val classLoader: ClassLoader,
+ val context: VectorContext,
+ entries: List,
+ val isSystemServer: Boolean,
+ val processName: String,
+ ) {
+ private val entryRefs = entries.map { WeakReference(it) }
+
+ fun liveEntries(): List =
+ entryRefs.mapNotNull { it.get() }.filter { VectorLifecycleManager.isActive(it) }
+ }
+
+ private val generations = ConcurrentHashMap()
+
+ // Reloads are serialized per module within this process; the daemon serializes per target.
+ private val reloadLocks = ConcurrentHashMap()
+
/**
* Loads a module APK, instantiates its entry classes, and binds them to the Vector framework.
*/
fun loadModule(module: Module, isSystemServer: Boolean, processName: String): Boolean {
+ val (generation, entries) =
+ buildGeneration(module, isSystemServer, processName) ?: return false
+
+ entries.forEach { VectorLifecycleManager.activeModules.add(it) }
+ generations[module.packageName] = generation
+
+ val param =
+ object : ModuleLoadedParam {
+ override fun isSystemServer(): Boolean = isSystemServer
+
+ override fun getProcessName(): String = processName
+ }
+ entries.forEach { entry ->
+ runCatching { entry.onModuleLoaded(param) }
+ .onFailure { e ->
+ Log.e(TAG, "Error in onModuleLoaded for ${entry.javaClass.name}", e)
+ }
+ }
+
+ // Register any native JNI entrypoints declared by the module
+ module.file.moduleLibraryNames.forEach { libraryName ->
+ NativeAPI.recordNativeEntrypoint(libraryName)
+ }
+
+ Log.d(TAG, "Loaded module ${module.packageName} successfully.")
+ return true
+ }
+
+ // Publishes nothing, so a reload can fail before the old generation is touched.
+ private fun buildGeneration(
+ module: Module,
+ isSystemServer: Boolean,
+ processName: String,
+ ): Pair>? {
try {
Log.d(TAG, "Loading module ${module.packageName}")
@@ -45,6 +109,7 @@ object VectorModuleManager {
module.file.preLoadedDexes,
librarySearchPath,
initLoader,
+ blockLegacyApi = module.file.targetApiVersion >= 102,
)
// Security/Integrity Check: Ensure the module isn't bundling its own API classes
@@ -53,7 +118,7 @@ object VectorModuleManager {
initLoader
) {
Log.e(TAG, "The Xposed API classes are compiled into ${module.packageName}")
- return false
+ return null
}
// Create the Context that will be injected into the module
@@ -65,6 +130,7 @@ object VectorModuleManager {
)
// Instantiate the module entry classes
+ val entries = mutableListOf()
for (className in module.file.moduleClassNames) {
runCatching {
val moduleClass = moduleClassLoader.loadClass(className)
@@ -79,34 +145,203 @@ object VectorModuleManager {
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)
-
- // Trigger the initial onModuleLoaded callback
- moduleInstance.onModuleLoaded(
- object : ModuleLoadedParam {
- override fun isSystemServer(): Boolean = isSystemServer
+ // detach() is per entry: only the instance that calls it stops.
+ moduleInstance.attachFramework(vectorContext) {
+ VectorLifecycleManager.detach(moduleInstance)
+ }
- override fun getProcessName(): String = processName
- }
- )
+ entries.add(moduleInstance)
}
.onFailure { e -> Log.e(TAG, "Failed to instantiate class $className", e) }
}
- // Register any native JNI entrypoints declared by the module
- module.file.moduleLibraryNames.forEach { libraryName ->
- NativeAPI.recordNativeEntrypoint(libraryName)
- }
-
- Log.d(TAG, "Loaded module ${module.packageName} successfully.")
- return true
+ val generation =
+ Generation(moduleClassLoader, vectorContext, entries, isSystemServer, processName)
+ return generation to entries
} catch (e: Throwable) {
Log.e(TAG, "Fatal error loading module ${module.packageName}", e)
- return false
+ return null
+ }
+ }
+
+ fun hotReload(
+ modulePackageName: String?,
+ extras: Bundle?,
+ newModule: Module?,
+ ): HotReloadOutcome {
+ val packageName =
+ modulePackageName ?: return unsupported("Hot reload was requested without a module")
+ val lock = reloadLocks.computeIfAbsent(packageName) { ReentrantLock() }
+ if (!lock.tryLock()) {
+ return outcome(
+ IXposedService.HOT_RELOAD_IN_PROGRESS,
+ "A reload of $packageName is already running in this process",
+ )
+ }
+ return try {
+ runHotReload(packageName, extras, newModule)
+ } catch (t: Throwable) {
+ Log.e(TAG, "Hot reload of $packageName failed", t)
+ failed(describe(t))
+ } finally {
+ lock.unlock()
+ }
+ }
+
+ private fun runHotReload(
+ packageName: String,
+ extras: Bundle?,
+ newModule: Module?,
+ ): HotReloadOutcome {
+ if (newModule == null) {
+ return unsupported("No new generation of $packageName was supplied")
+ }
+ val old =
+ generations[packageName]
+ ?: return unsupported(
+ "$packageName is not loaded in ${VectorServiceClient.processName}"
+ )
+ if (newModule.file.moduleClassNames.size != 1) {
+ return unsupported("$packageName does not declare exactly one Java entry class")
+ }
+
+ // Keeps the old generation reachable until onHotReloaded has finished.
+ val oldEntries = old.liveEntries()
+ if (oldEntries.isEmpty()) {
+ // Not a refusal: a null message means onHotReloading returned false, and nothing ran.
+ Log.w(TAG, "No attached entry of $packageName can accept a hot reload")
+ return unsupported("Every entry of $packageName has detached in this process")
+ }
+
+ val built =
+ buildGeneration(newModule, old.isSystemServer, old.processName)
+ ?: return unsupported("Cannot build a new generation of $packageName")
+ val (newGeneration, newEntries) = built
+
+ // Before the callback, so registrations from inside it fail while unhook and replace work.
+ old.context.freeze()
+
+ var savedState: Any? = null
+ val reloadingParam =
+ object : HotReloadingParam {
+ override fun getExtras(): Bundle? = extras
+
+ override fun setSavedInstanceState(outState: Any?) {
+ rejectOldGenerationState(outState, old.classLoader)
+ savedState = outState
+ }
+ }
+
+ val accepted =
+ try {
+ // One refusal cancels the reload for the whole module.
+ oldEntries.all { it.onHotReloading(reloadingParam) }
+ } catch (t: Throwable) {
+ old.context.unfreeze()
+ Log.e(TAG, "onHotReloading of $packageName threw", t)
+ return failed(describe(t))
+ }
+ if (!accepted) {
+ old.context.unfreeze()
+ Log.d(TAG, "$packageName refused the hot reload")
+ return refusal()
+ }
+
+ // Captured after the freeze and after old code had its chance to unhook.
+ val oldHandles = VectorHookBuilder.snapshotHandles(packageName)
+ // replaceHook swaps the hooker inside an installed record, so tracking must survive the
+ // reload; the rollback below undoes only what the new generation adds on top of this.
+ val inherited = VectorHookBuilder.trackedRecords(packageName)
+
+ oldEntries.forEach { VectorLifecycleManager.activeModules.remove(it) }
+ // Active before the callback, so an entry detaching from inside it is honoured.
+ newEntries.forEach { VectorLifecycleManager.activeModules.add(it) }
+
+ val reloadedParam =
+ object : HotReloadedParam {
+ override fun isSystemServer(): Boolean = old.isSystemServer
+
+ override fun getProcessName(): String = old.processName
+
+ override fun getExtras(): Bundle? = extras
+
+ override fun getSavedInstanceState(): Any? = savedState
+
+ override fun getOldHookHandles(): List = oldHandles
+ }
+
+ try {
+ // The default onHotReloaded already unhooks these; doing both would double-unhook.
+ newEntries.filter { VectorLifecycleManager.isActive(it) }.forEach {
+ it.onHotReloaded(reloadedParam)
+ }
+ } catch (t: Throwable) {
+ // Nothing has been committed yet, so the old generation is still the live one.
+ VectorHookBuilder.unhookSince(packageName, inherited)
+ newEntries.forEach { VectorLifecycleManager.activeModules.remove(it) }
+ oldEntries.forEach { VectorLifecycleManager.activeModules.add(it) }
+ old.context.unfreeze()
+ Log.e(TAG, "onHotReloaded of $packageName threw; kept the previous generation", t)
+ return failed(describe(t))
+ }
+
+ // Commit only now that the new code has taken over. Replacing the map entry drops the last
+ // framework-owned reference to the old generation; oldEntries dies with this frame.
+ generations[packageName] = newGeneration
+ Log.d(TAG, "Hot reloaded $packageName")
+ return outcome(IXposedService.HOT_RELOAD_SUCCEEDED, null)
+ }
+
+ /**
+ * Rejects saved state that the old generation created, which would otherwise keep the retired
+ * classloader reachable through the new one. A shallow scan, as the API describes it: a
+ * diagnostic aid rather than an object graph verifier.
+ */
+ private fun rejectOldGenerationState(state: Any?, oldClassLoader: ClassLoader) {
+ if (state == null) return
+ reject(state, oldClassLoader)
+ when (state) {
+ is Array<*> -> state.forEach { it?.let { e -> reject(e, oldClassLoader) } }
+ is Collection<*> -> state.forEach { it?.let { e -> reject(e, oldClassLoader) } }
+ is Map<*, *> ->
+ state.forEach { (k, v) ->
+ k?.let { reject(it, oldClassLoader) }
+ v?.let { reject(it, oldClassLoader) }
+ }
+ }
+ }
+
+ private fun reject(value: Any, oldClassLoader: ClassLoader) {
+ if (definedBy(value.javaClass, oldClassLoader)) {
+ throw IllegalArgumentException(
+ "Saved instance state contains ${value.javaClass.name}, which was created under " +
+ "the old module classloader"
+ )
}
}
+
+ private fun definedBy(clazz: Class<*>, classLoader: ClassLoader): Boolean {
+ var loader: ClassLoader? =
+ (if (clazz.isArray) clazz.componentType else clazz)?.classLoader
+ while (loader != null) {
+ if (loader === classLoader) return true
+ loader = loader.parent
+ }
+ return false
+ }
+
+ private fun outcome(status: Int, message: String?, refused: Boolean = false) =
+ HotReloadOutcome().apply {
+ this.status = status
+ this.message = message
+ this.refused = refused
+ }
+
+ private fun unsupported(message: String) = outcome(IXposedService.HOT_RELOAD_UNSUPPORTED, message)
+
+ private fun failed(message: String) = outcome(IXposedService.HOT_RELOAD_FAILED, message)
+
+ private fun refusal() = outcome(IXposedService.HOT_RELOAD_FAILED, null, refused = true)
+
+ private fun describe(t: Throwable) = "${t.javaClass.name}: ${t.message ?: "no message"}"
}
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt
index fc70e9097..163d4f31c 100644
--- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt
+++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorServiceClient.kt
@@ -3,6 +3,7 @@ package org.matrix.vector.impl.core
import android.os.IBinder
import android.os.ParcelFileDescriptor
import org.lsposed.lspd.models.Module
+import org.lsposed.lspd.service.IHotReloadTarget
import org.lsposed.lspd.service.ILSPApplicationService
import org.lsposed.lspd.util.Utils.Log
@@ -31,6 +32,24 @@ object VectorServiceClient : ILSPApplicationService, IBinder.DeathRecipient {
Log.e(TAG, "Failed to link to death for service in process: $niceName", it)
service = null
}
+
+ // Registered here rather than after module loading: system_server loads its modules
+ // before the daemon's module cache exists, and it has to be a reloadable target too.
+ service?.let {
+ try {
+ it.registerHotReloadTarget(VectorHotReloadTarget)
+ } catch (t: Throwable) {
+ Log.e(TAG, "Failed to register the hot reload target in process: $niceName", t)
+ }
+ }
+ }
+ }
+
+ override fun registerHotReloadTarget(target: IHotReloadTarget?) {
+ try {
+ service?.registerHotReloadTarget(target)
+ } catch (t: Throwable) {
+ Log.e(TAG, "Failed to register a hot reload target", t)
}
}
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt
index 4a423c108..36d659603 100644
--- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt
+++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorChain.kt
@@ -1,31 +1,65 @@
package org.matrix.vector.impl.hooks
-import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedInterface.Chain
import io.github.libxposed.api.XposedInterface.ExceptionMode
+import io.github.libxposed.api.XposedInterface.Hooker
import java.lang.reflect.Executable
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
import org.lsposed.lspd.util.Utils
/** Represents a registered hook configuration, stored natively by [HookBridge]. */
-data class VectorHookRecord(
- val hooker: XposedInterface.Hooker,
+class VectorHookRecord(
+ // Mutable so a hook can be replaced in place. The native layer indexes this record by object
+ // identity, so swapping the hooker is invisible to it: there is always exactly one record in the
+ // callback map, hence no window in which two hookers both look active.
+ @Volatile var hooker: Hooker,
val priority: Int,
val exceptionMode: ExceptionMode,
-)
+ val id: String?,
+) {
+ // Bumped on every replacement; a hook handle stays valid only while its captured value matches.
+ val epoch = AtomicInteger(0)
+
+ // Cleared on unhook to make unhook idempotent and to invalidate outstanding handles.
+ val installed = AtomicBoolean(true)
+}
/**
* Core interceptor chain engine. Manages recursive hook execution and enforces [ExceptionMode]
* protections.
*/
-class VectorChain(
+class VectorChain
+private constructor(
private val executable: Executable,
private val thisObj: Any?,
private val args: Array,
private val hooks: Array,
+ // Frozen snapshot of the hookers, captured once at the root so replacing a hooker mid-call does
+ // not affect this in-flight call (the chain is snapshot based).
+ private val hookers: Array,
private val hookIndex: Int,
private val terminal: (thisObj: Any?, args: Array) -> Any?,
) : Chain {
+ /** Entry point used to start a call; freezes the current hooker list once for the whole call. */
+ constructor(
+ executable: Executable,
+ thisObj: Any?,
+ args: Array,
+ hooks: Array,
+ hookIndex: Int,
+ terminal: (thisObj: Any?, args: Array) -> Any?,
+ ) : this(
+ executable,
+ thisObj,
+ args,
+ hooks,
+ Array(hooks.size) { hooks[it].hooker },
+ hookIndex,
+ terminal,
+ )
+
// Tracks if this specific chain node has forwarded execution downstream
internal var proceedCalled: Boolean = false
private set
@@ -59,14 +93,23 @@ class VectorChain(
return executeDownstream { terminal(thisObject, currentArgs) }
}
- val record = hooks[hookIndex]
+ val hooker = hookers[hookIndex]
+ val exceptionMode = hooks[hookIndex].exceptionMode
val nextChain =
- VectorChain(executable, thisObject, currentArgs, hooks, hookIndex + 1, terminal)
+ VectorChain(
+ executable,
+ thisObject,
+ currentArgs,
+ hooks,
+ hookers,
+ hookIndex + 1,
+ terminal,
+ )
return try {
- executeDownstream { record.hooker.intercept(nextChain) }
+ executeDownstream { hooker.intercept(nextChain) }
} catch (t: Throwable) {
- handleInterceptorException(t, record, nextChain, thisObject, currentArgs)
+ handleInterceptorException(t, hooker, exceptionMode, nextChain, thisObject, currentArgs)
}
}
@@ -88,7 +131,8 @@ class VectorChain(
/** Handles exceptions thrown by a hooker according to its [ExceptionMode]. */
private fun handleInterceptorException(
t: Throwable,
- record: VectorHookRecord,
+ hooker: Hooker,
+ exceptionMode: ExceptionMode,
nextChain: VectorChain,
recoveryThis: Any?,
recoveryArgs: Array,
@@ -99,11 +143,11 @@ class VectorChain(
}
// Passthrough mode does not rescue the process from hooker crashes
- if (record.exceptionMode == ExceptionMode.PASSTHROUGH) {
+ if (exceptionMode == ExceptionMode.PASSTHROUGH) {
throw t
}
- val hookerName = record.hooker.javaClass.name
+ val hookerName = hooker.javaClass.name
if (!nextChain.proceedCalled) {
// Crash occurred before calling proceed(); skip hooker and continue the chain
Utils.logD("Hooker [$hookerName] crashed before proceed. Skipping.", t)
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorNativeHooker.kt
index cbfdb7506..68aadc8a8 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,24 @@ import java.lang.reflect.Executable
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Modifier
+import java.util.concurrent.ConcurrentHashMap
import org.lsposed.lspd.util.Utils
import org.matrix.vector.impl.di.VectorBootstrap
import org.matrix.vector.nativebridge.HookBridge
-/** Builder for configuring and registering hooks. */
-class VectorHookBuilder(private val origin: Executable) : HookBuilder {
+/**
+ * Builder for configuring and registering hooks. [moduleId] scopes hook ids per module and is null
+ * for framework hooks; [frozen] gates registration only, so a retired generation can still unhook.
+ */
+class VectorHookBuilder(
+ private val origin: Executable,
+ private val moduleId: Any? = null,
+ private val frozen: (() -> Boolean)? = null,
+) : HookBuilder {
private var priority = XposedInterface.PRIORITY_DEFAULT
private var exceptionMode = ExceptionMode.DEFAULT
+ private var id: String? = null
override fun setPriority(priority: Int): HookBuilder = apply { this.priority = priority }
@@ -26,7 +35,14 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder {
this.exceptionMode = mode
}
+ override fun setId(id: String?): HookBuilder = apply { this.id = id }
+
override fun intercept(hooker: Hooker): HookHandle {
+ if (frozen?.invoke() == true) {
+ throw IllegalStateException(
+ "This module generation has been retired by a hot reload and cannot register hooks"
+ )
+ }
if (Modifier.isAbstract(origin.modifiers)) {
throw IllegalArgumentException("Cannot hook abstract methods: $origin")
} else if (origin.declaringClass.classLoader == VectorHookBuilder::class.java.classLoader) {
@@ -39,7 +55,41 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder {
throw IllegalArgumentException("Cannot hook Method.invoke")
}
- val record = VectorHookRecord(hooker, priority, exceptionMode)
+ val id = this.id
+ if (id != null) {
+ val key = IdKey(moduleId, origin, id)
+ // putIfAbsent is the only atomic point: a check-then-act would let two threads install
+ // two records for one id, which is the duplication this design exists to avoid.
+ val candidate = VectorHookRecord(hooker, priority, exceptionMode, id)
+ while (true) {
+ val existing = idRegistry.putIfAbsent(key, candidate) ?: break
+ if (existing.installed.get()) {
+ // Replace in place rather than installing a second native record.
+ val epoch = existing.epoch.incrementAndGet()
+ existing.hooker = hooker
+ return handleFor(existing, epoch)
+ }
+ // The id is held by a record that has since been unhooked; drop it and retry.
+ idRegistry.remove(key, existing)
+ }
+
+ if (
+ !HookBridge.hookMethod(
+ true,
+ origin,
+ VectorNativeHooker::class.java,
+ priority,
+ candidate,
+ )
+ ) {
+ idRegistry.remove(key, candidate)
+ throw HookFailedError("Cannot hook $origin")
+ }
+ track(candidate)
+ return handleFor(candidate, candidate.epoch.get())
+ }
+
+ val record = VectorHookRecord(hooker, priority, exceptionMode, null)
// Register natively. HookBridge now stores VectorHookRecord instead of HookerCallback.
if (
@@ -48,16 +98,91 @@ class VectorHookBuilder(private val origin: Executable) : HookBuilder {
throw HookFailedError("Cannot hook $origin")
}
- return object : HookHandle {
- override fun getExecutable(): Executable = origin
+ track(record)
+ return handleFor(record, record.epoch.get())
+ }
+
+ private fun track(record: VectorHookRecord) {
+ val moduleId = this.moduleId ?: return
+ moduleHooks
+ .computeIfAbsent(moduleId) { ConcurrentHashMap.newKeySet() }
+ .add(InstalledHook(origin, record))
+ }
+
+ private fun handleFor(record: VectorHookRecord, epoch: Int): HookHandle =
+ handleFor(origin, moduleId, record, epoch)
- override fun unhook() {
- HookBridge.unhookMethod(true, origin, record)
+ companion object {
+ // Keyed by (module, executable, id) so a repeated intercept() reuses the installed record.
+ private val idRegistry = ConcurrentHashMap()
+
+ private val moduleHooks = ConcurrentHashMap>()
+
+ // Stale once the record is replaced (epoch moves on) or unhooked.
+ private fun handleFor(
+ origin: Executable,
+ moduleId: Any?,
+ record: VectorHookRecord,
+ epoch: Int,
+ ): HookHandle =
+ object : HookHandle {
+ override fun getExecutable(): Executable = origin
+
+ override fun getId(): String? = record.id
+
+ override fun unhook() {
+ if (record.installed.compareAndSet(true, false)) {
+ HookBridge.unhookMethod(true, origin, record)
+ record.id?.let { idRegistry.remove(IdKey(moduleId, origin, it), record) }
+ moduleId?.let {
+ moduleHooks[it]?.remove(InstalledHook(origin, record))
+ }
+ }
+ }
+
+ override fun replaceHook(hooker: Hooker): HookHandle {
+ // The epoch CAS also makes concurrent replacements mutually exclusive.
+ if (!record.installed.get() || !record.epoch.compareAndSet(epoch, epoch + 1)) {
+ throw IllegalStateException("Hook handle is no longer valid")
+ }
+ record.hooker = hooker
+ return handleFor(origin, moduleId, record, epoch + 1)
+ }
}
+
+ // Minted at the current epoch so the receiver can still replace them.
+ fun snapshotHandles(moduleId: Any): List =
+ moduleHooks[moduleId]
+ ?.filter { it.record.installed.get() }
+ ?.map { handleFor(it.origin, moduleId, it.record, it.record.epoch.get()) }
+ ?: emptyList()
+
+ fun trackedRecords(moduleId: Any): Set =
+ moduleHooks[moduleId]?.mapTo(mutableSetOf()) { it.record } ?: emptySet()
+
+ // Tracking survives a reload: replaceHook swaps the hooker inside an installed record,
+ // so forgetting it would strand a live hook the framework can no longer hand back.
+ fun unhookSince(moduleId: Any, keep: Set) {
+ val tracked = moduleHooks[moduleId] ?: return
+ tracked
+ .filter { it.record !in keep }
+ .forEach { hook ->
+ if (hook.record.installed.compareAndSet(true, false)) {
+ HookBridge.unhookMethod(true, hook.origin, hook.record)
+ }
+ tracked.remove(hook)
+ hook.record.id?.let {
+ idRegistry.remove(IdKey(moduleId, hook.origin, it), hook.record)
+ }
+ }
}
}
}
+private data class InstalledHook(val origin: Executable, val record: VectorHookRecord)
+
+private data class IdKey(val moduleId: Any?, val executable: Executable, val id: String)
+
/**
* The native callback entrypoint. Instantiated natively by [HookBridge] when a hooked method is
* hit.
diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt
index d4ec60895..c6012e045 100644
--- a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt
+++ b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt
@@ -26,6 +26,7 @@ import java.util.zip.ZipEntry
class VectorModuleClassLoader : ByteBufferDexClassLoader {
private val apkPath: String
+ private val blockLegacyApi: Boolean
private val nativeLibraryDirs = mutableListOf()
@RequiresApi(Build.VERSION_CODES.Q)
@@ -34,8 +35,10 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader {
librarySearchPath: String?,
parent: ClassLoader?,
apkPath: String,
+ blockLegacyApi: Boolean,
) : super(dexBuffers, librarySearchPath, parent) {
this.apkPath = apkPath
+ this.blockLegacyApi = blockLegacyApi
initNativeDirs(librarySearchPath)
}
@@ -44,8 +47,10 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader {
parent: ClassLoader?,
apkPath: String,
librarySearchPath: String?,
+ blockLegacyApi: Boolean,
) : super(dexBuffers, parent) {
this.apkPath = apkPath
+ this.blockLegacyApi = blockLegacyApi
initNativeDirs(librarySearchPath)
}
@@ -57,6 +62,16 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader {
@Throws(ClassNotFoundException::class)
override fun loadClass(name: String, resolve: Boolean): Class<*> {
+ // API 102 forbids libxposed modules from calling the legacy de.robv APIs. This loader's
+ // parent is the framework's own loader, which carries the legacy bridge, so refusing to
+ // resolve the package here is what actually enforces it - reflective lookups against this
+ // loader included.
+ if (blockLegacyApi && name.startsWith(LEGACY_API_PREFIX)) {
+ throw ClassNotFoundException(
+ "$name is unavailable to modules targeting Xposed API 102 or higher"
+ )
+ }
+
findLoadedClass(name)?.let {
return it
}
@@ -130,6 +145,7 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader {
companion object {
private const val TAG = "VectorModuleClassLoader"
private const val ZIP_SEPARATOR = "!/"
+ private const val LEGACY_API_PREFIX = "de.robv.android.xposed."
private val SYSTEM_NATIVE_LIBRARY_DIRS = splitPaths(System.getProperty("java.library.path"))
private fun splitPaths(searchPath: String?): List {
@@ -143,11 +159,13 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader {
* fully instantiated.
*/
@JvmStatic
+ @JvmOverloads
fun loadApk(
apk: String,
dexes: List,
librarySearchPath: String,
parent: ClassLoader?,
+ blockLegacyApi: Boolean = false,
): ClassLoader {
val dexBuffers =
dexes
@@ -166,9 +184,21 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader {
val cl =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- VectorModuleClassLoader(dexBuffers, librarySearchPath, parent, apk)
+ VectorModuleClassLoader(
+ dexBuffers,
+ librarySearchPath,
+ parent,
+ apk,
+ blockLegacyApi,
+ )
} else {
- VectorModuleClassLoader(dexBuffers, parent, apk, librarySearchPath)
+ VectorModuleClassLoader(
+ dexBuffers,
+ parent,
+ apk,
+ librarySearchPath,
+ blockLegacyApi,
+ )
}
dexBuffers.toList().parallelStream().forEach { SharedMemory.unmap(it) }